Compare commits

..

10 Commits

Author SHA1 Message Date
951a1f64ac mas cambios 2026-03-12 10:54:46 -04:00
gpt-engineer-app[bot]
6b1e9a25af Add responsive design 2025-10-13 15:26:37 +00:00
gpt-engineer-app[bot]
bdb8b2d7e2 Refactor: Analyze project documentation 2025-10-12 01:00:32 +00:00
gpt-engineer-app[bot]
4bd776e3bd Fix: Resolve security warnings for production 2025-10-12 00:53:43 +00:00
gpt-engineer-app[bot]
5e25164a56 Refactor: Analyze and fix security issues 2025-10-12 00:50:51 +00:00
gpt-engineer-app[bot]
6793ea6e3e Refactor: Move Roles & Permissions 2025-10-12 00:46:18 +00:00
gpt-engineer-app[bot]
106e4d852d Add roles menu to Admin Panel 2025-10-11 20:32:44 +00:00
gpt-engineer-app[bot]
2f6983aa41 Implement roles and permissions 2025-10-11 16:57:48 +00:00
gpt-engineer-app[bot]
ff1312ae50 feat: Implement chat module in sidebar 2025-10-11 16:44:26 +00:00
gpt-engineer-app[bot]
45963fa7ba Refactor Admin Panel navigation 2025-10-11 16:39:18 +00:00
52 changed files with 10366 additions and 1499 deletions

387
README.md
View File

@@ -1,73 +1,368 @@
# Welcome to your Lovable project
# 🌴 Karibeo - Tourism & Business Management Platform
## Project info
[![React](https://img.shields.io/badge/React-18.3.1-blue.svg)](https://reactjs.org/)
[![TypeScript](https://img.shields.io/badge/TypeScript-5.5.3-blue.svg)](https://www.typescriptlang.org/)
[![Tailwind CSS](https://img.shields.io/badge/Tailwind-3.4.1-38bdf8.svg)](https://tailwindcss.com/)
[![License](https://img.shields.io/badge/License-MIT-green.svg)](LICENSE)
**URL**: https://lovable.dev/projects/0a15968a-5628-423b-852e-32fae49619db
Karibeo es una plataforma integral de gestión turística y comercial diseñada para conectar turistas con servicios locales mientras proporciona herramientas empresariales avanzadas para hoteles, restaurantes y comercios.
## How can I edit this code?
## 🚀 Características Principales
There are several ways of editing your application.
### Para Turistas 🧳
- **Exploración Interactiva**: Descubre destinos, lugares y experiencias únicas
- **Reservas Integradas**: Sistema completo de reservas para hoteles, restaurantes y tours
- **Geolocalización Avanzada**: Encuentra servicios cercanos con mapas interactivos
- **Billetera Digital**: Gestión de pagos y transacciones seguras
- **Reseñas y Valoraciones**: Sistema completo de reviews y puntuaciones
- **Asistente IA**: Asistente flotante con inteligencia artificial para ayuda personalizada
**Use Lovable**
### Para Negocios 💼
Simply visit the [Lovable Project](https://lovable.dev/projects/0a15968a-5628-423b-852e-32fae49619db) and start prompting.
#### 🏨 Gestión Hotelera
- Sistema de reservas y check-in/check-out
- Gestión de habitaciones e inventario
- Room service y amenidades
- Control de acceso sin llaves (keyless entry)
- Gestión de personal y turnos
Changes made via Lovable will be committed automatically to this repo.
#### 🍽️ Sistema POS Restaurante
- Terminal de punto de venta completa
- Gestión de pedidos y cocina
- Sistema de mesas y reservaciones
- Gestión de menú dinámico
- Control de inventario
- Facturación y cuentas
**Use your preferred IDE**
#### 🏪 Gestión de Comercio
- Tienda virtual integrada
- Terminal POS para ventas
- Gestión de inventario y productos
- Control de clientes y fidelización
- Sistema de caja y reportes
- Gestión de personal
If you want to work locally using your own IDE, you can clone this repo and push changes. Pushed changes will also be reflected in Lovable.
### Panel de Administración 👨‍💼
- **Dashboard Ejecutivo**: Métricas en tiempo real y KPIs
- **Gestión de Usuarios**: Control completo de usuarios y perfiles
- **Sistema de Roles y Permisos**: Gestión granular por entidad (Admin, Hotel, Restaurante, Comercio)
- **CRM Integrado**: Gestión de contactos, campañas y analytics
- **Channel Manager**: Sincronización multi-canal (Booking, Airbnb, Expedia)
- **Sistema de Comisiones**: Reglas automatizadas y tracking
- **Configuración Global**: APIs, pagos, integraciones
- **Security Center**: Monitoreo y auditoría de seguridad
- **Analytics Avanzado**: Reportes y visualización de datos
The only requirement is having Node.js & npm installed - [install with nvm](https://github.com/nvm-sh/nvm#installing-and-updating)
### Módulos Especiales 🌟
- **POLITUR**: Sistema de emergencias y seguridad turística
- **Guías Turísticos**: Creación y gestión de itinerarios personalizados
- **Sostenibilidad**: Tracking de impacto ambiental y certificaciones
- **Gestión de Vehículos**: Control de flotas y mantenimiento
Follow these steps:
## 🛠️ Stack Tecnológico
```sh
# Step 1: Clone the repository using the project's Git URL.
git clone <YOUR_GIT_URL>
### Frontend
- **Framework**: React 18.3.1 + TypeScript
- **Build Tool**: Vite 6.2.0
- **Styling**: Tailwind CSS + shadcn/ui components
- **State Management**: React Context + TanStack Query
- **Routing**: React Router DOM 7.8.2
- **Forms**: React Hook Form + Zod validation
- **Maps**: Google Maps API
- **Charts**: Recharts + ApexCharts
# Step 2: Navigate to the project directory.
cd <YOUR_PROJECT_NAME>
# Step 3: Install the necessary dependencies.
npm i
# Step 4: Start the development server with auto-reloading and an instant preview.
npm run dev
### Dependencias Clave
```json
{
"@tanstack/react-query": "^5.83.0",
"@googlemaps/js-api-loader": "^1.16.10",
"react-hook-form": "^7.61.1",
"zod": "^3.25.76",
"recharts": "^3.1.2",
"lucide-react": "^0.462.0"
}
```
**Edit a file directly in GitHub**
## 📁 Estructura del Proyecto
- Navigate to the desired file(s).
- Click the "Edit" button (pencil icon) at the top right of the file view.
- Make your changes and commit the changes.
```
karibeo/
├── src/
│ ├── components/ # Componentes reutilizables
│ │ ├── admin/ # Componentes de administración
│ │ ├── hotel/ # Componentes hoteleros
│ │ ├── restaurant/ # Componentes de restaurante
│ │ ├── roles/ # Sistema de roles
│ │ ├── security/ # Componentes de seguridad
│ │ └── ui/ # Componentes UI base (shadcn)
│ ├── contexts/ # React Contexts
│ │ ├── AuthContext.tsx
│ │ ├── CartContext.tsx
│ │ └── CurrencyContext.tsx
│ ├── hooks/ # Custom React Hooks
│ │ ├── useAdminData.ts
│ │ ├── useBooking.ts
│ │ └── useRolesPermissions.ts
│ ├── pages/ # Páginas/Rutas
│ │ ├── dashboard/ # Panel de control
│ │ ├── SignIn.tsx
│ │ └── SignUp.tsx
│ ├── services/ # Servicios API
│ │ ├── adminApi.ts
│ │ ├── emergencyApi.ts
│ │ └── paymentService.ts
│ ├── types/ # Definiciones TypeScript
│ ├── lib/ # Utilidades
│ │ ├── utils.ts
│ │ └── validation.ts # Schemas de validación Zod
│ ├── i18n/ # Internacionalización
│ └── App.tsx # Componente principal
├── public/ # Assets estáticos
├── tailwind.config.ts # Configuración Tailwind
├── tsconfig.json # Configuración TypeScript
└── vite.config.ts # Configuración Vite
```
**Use GitHub Codespaces**
## 🚦 Instalación y Configuración
- Navigate to the main page of your repository.
- Click on the "Code" button (green button) near the top right.
- Select the "Codespaces" tab.
- Click on "New codespace" to launch a new Codespace environment.
- Edit files directly within the Codespace and commit and push your changes once you're done.
### Prerrequisitos
- Node.js 18+ (recomendado usar [nvm](https://github.com/nvm-sh/nvm))
- npm o yarn
## What technologies are used for this project?
### Instalación Local
This project is built with:
```bash
# 1. Clonar el repositorio
git clone https://github.com/tu-usuario/karibeo.git
cd karibeo
- Vite
- TypeScript
- React
- shadcn-ui
- Tailwind CSS
# 2. Instalar dependencias
npm install
## How can I deploy this project?
# 3. Configurar variables de entorno
# Crear archivo .env en la raíz (ver sección de Configuración)
Simply open [Lovable](https://lovable.dev/projects/0a15968a-5628-423b-852e-32fae49619db) and click on Share -> Publish.
# 4. Iniciar servidor de desarrollo
npm run dev
## Can I connect a custom domain to my Lovable project?
# El proyecto estará disponible en http://localhost:5173
```
Yes, you can!
### Scripts Disponibles
To connect a domain, navigate to Project > Settings > Domains and click Connect Domain.
```bash
npm run dev # Servidor de desarrollo
npm run build # Build de producción
npm run preview # Preview del build
npm run lint # Linting con ESLint
```
Read more here: [Setting up a custom domain](https://docs.lovable.dev/tips-tricks/custom-domain#step-by-step-guide)
## 🔐 Autenticación y Seguridad
### Sistema de Autenticación
- Login mediante email/password con validación robusta
- Tokens JWT con refresh token automático
- Soporte para OAuth (Google, Apple)
- Sesiones persistentes en localStorage
### Validación de Inputs
Todos los formularios utilizan **Zod** para validación:
```typescript
// Ejemplo de schema de validación
const loginSchema = z.object({
email: z.string().email().max(255),
password: z.string().min(8).max(128)
});
```
### Roles y Permisos
Sistema granular de permisos por entidad:
- **Admin**: Gestión global del sistema
- **Hotel**: Permisos específicos para hoteles
- **Restaurant**: Permisos para restaurantes
- **Commerce**: Permisos para comercios
## 🌍 Internacionalización
Soporte multi-idioma con contexto dedicado:
- Español (ES) - Por defecto
- Inglés (EN)
- Fácilmente extensible a más idiomas
```typescript
// Uso del contexto de idioma
const { t, language, changeLanguage } = useLanguage();
<h1>{t('welcome')}</h1>
```
## 💰 Sistema de Pagos
Integración con múltiples pasarelas:
- Stripe
- PayPal
- Pagos locales
Gestión de:
- Billetera digital integrada
- Transacciones seguras
- Historial y facturas
- Sistema de comisiones automatizado
## 📊 Analytics y Reportes
### Métricas Disponibles
- KPIs en tiempo real
- Reportes financieros
- Analytics de usuarios
- Tasa de conversión
- Ocupación y reservas
- Ventas y rendimiento
### Visualizaciones
- Gráficos de línea y barra
- Mapas de calor
- Tablas interactivas
- Exportación a PDF/Excel
## 🗺️ Integración de Mapas
Google Maps integrado para:
- Geolocalización de servicios
- Geofences y alertas
- Navegación en tiempo real
- Tracking de vehículos
- Puntos de interés (POI)
## 📱 Responsive Design
Diseño completamente responsive:
- Mobile-first approach
- Breakpoints: sm, md, lg, xl, 2xl
- Touch-friendly interfaces
- PWA capabilities
## 🔌 API Endpoints
### Base URL
```
https://karibeo.lesoluciones.net:8443/api/v1
```
### Endpoints Principales
#### Autenticación
```typescript
POST /auth/login
POST /auth/register
POST /auth/refresh
GET /auth/profile
```
#### Usuarios
```typescript
GET /admin/users
POST /admin/users
PUT /admin/users/:id
DELETE /admin/users/:id
```
#### Reservas
```typescript
GET /reservations
POST /reservations
PUT /reservations/:id
DELETE /reservations/:id
```
#### Establecimientos
```typescript
GET /establishments
POST /establishments
GET /establishments/:id
```
## 🧪 Testing
```bash
# Ejecutar tests
npm run test
# Coverage
npm run test:coverage
```
## 📦 Deployment
### Build de Producción
```bash
npm run build
```
Los archivos se generarán en `/dist`
### Deploy en Lovable
1. Ir a [Lovable Project](https://lovable.dev/projects/0a15968a-5628-423b-852e-32fae49619db)
2. Click en **Share → Publish**
3. Configurar dominio personalizado (opcional)
### Deploy en Otros Servicios
- **Vercel**: `vercel deploy`
- **Netlify**: Conectar repositorio GitHub
- **AWS S3**: Subir carpeta `/dist`
## 🔧 Configuración Avanzada
### Variables de Entorno
```env
VITE_API_BASE_URL=https://karibeo.lesoluciones.net:8443/api/v1
VITE_GOOGLE_MAPS_API_KEY=tu_api_key
VITE_STRIPE_PUBLIC_KEY=tu_stripe_key
```
### Tailwind Design Tokens
Sistema de diseño centralizado en `index.css`:
- Variables CSS personalizadas
- Tema claro/oscuro
- Gradientes y sombras
- Animaciones
## 🤝 Contribución
### Workflow
1. Fork el proyecto
2. Crear feature branch (`git checkout -b feature/AmazingFeature`)
3. Commit cambios (`git commit -m 'Add: AmazingFeature'`)
4. Push al branch (`git push origin feature/AmazingFeature`)
5. Abrir Pull Request
### Convenciones de Código
- **TypeScript** estricto
- **ESLint** para linting
- **Prettier** para formateo
- Commits semánticos (feat, fix, docs, style, refactor, test, chore)
## 📄 Licencia
Este proyecto está bajo la licencia MIT. Ver archivo [LICENSE](LICENSE) para más detalles.
## 🆘 Soporte
- **Documentación**: [docs.karibeo.com](https://docs.karibeo.com)
- **Issues**: [GitHub Issues](https://github.com/tu-usuario/karibeo/issues)
- **Email**: soporte@karibeo.com
- **Discord**: [Comunidad Karibeo](https://discord.gg/karibeo)
## 🙏 Agradecimientos
- [shadcn/ui](https://ui.shadcn.com/) por los componentes UI
- [Lucide](https://lucide.dev/) por los iconos
- [React Icons](https://react-icons.github.io/react-icons/) por iconos adicionales
- Comunidad de código abierto
---
**Desarrollado con ❤️ para la industria turística del Caribe**
**Versión**: 1.0.0
**Última actualización**: 2025-01-12

729
docs/API.md Normal file
View File

@@ -0,0 +1,729 @@
# 📡 Documentación de API - Karibeo
## Base URL
```
https://karibeo.lesoluciones.net:8443/api/v1
```
## Autenticación
Todos los endpoints protegidos requieren un token JWT en el header:
```http
Authorization: Bearer <token>
```
### Obtener Token
```http
POST /auth/login
Content-Type: application/json
{
"email": "user@example.com",
"password": "securepassword"
}
```
**Respuesta**:
```json
{
"token": "eyJhbGciOiJIUzI1NiIs...",
"refreshToken": "eyJhbGciOiJIUzI1NiIs...",
"user": {
"id": "uuid",
"email": "user@example.com",
"name": "User Name",
"role": "admin"
}
}
```
### Refresh Token
```http
POST /auth/refresh
Content-Type: application/json
{
"refreshToken": "eyJhbGciOiJIUzI1NiIs..."
}
```
## Endpoints
### Autenticación
#### POST /auth/register
Registrar nuevo usuario
**Body**:
```json
{
"name": "John Doe",
"email": "john@example.com",
"password": "SecurePass123!",
"type": "tourist",
"location": {
"lat": 18.4861,
"lng": -69.9312
},
"preferences": {
"language": "es"
}
}
```
#### GET /auth/profile
Obtener perfil del usuario autenticado
**Respuesta**:
```json
{
"id": "uuid",
"email": "john@example.com",
"name": "John Doe",
"role": "tourist",
"avatar": "https://...",
"wallet": {
"balance": 150.50,
"currency": "USD"
}
}
```
### Usuarios (Admin)
#### GET /admin/users
Listar todos los usuarios
**Query Parameters**:
- `page` (int): Número de página (default: 1)
- `limit` (int): Items por página (default: 10)
- `role` (string): Filtrar por rol
**Respuesta**:
```json
{
"data": [
{
"id": "uuid",
"name": "User Name",
"email": "user@example.com",
"role": "tourist",
"status": "active",
"verified": true,
"createdAt": "2024-01-15T10:30:00Z"
}
],
"pagination": {
"total": 100,
"page": 1,
"pages": 10
}
}
```
#### POST /admin/users
Crear nuevo usuario
#### PUT /admin/users/:id
Actualizar usuario
#### DELETE /admin/users/:id
Eliminar usuario
### Establecimientos
#### GET /establishments
Listar establecimientos
**Query Parameters**:
- `type` (string): hotel, restaurant, commerce
- `page` (int)
- `limit` (int)
- `latitude` (float): Para búsqueda geográfica
- `longitude` (float)
- `radius` (float): Radio en km
**Respuesta**:
```json
{
"data": [
{
"id": "uuid",
"name": "Hotel Paradise",
"type": "hotel",
"rating": 4.5,
"location": {
"latitude": 18.4861,
"longitude": -69.9312,
"address": "Calle Principal #123"
},
"amenities": ["wifi", "pool", "restaurant"],
"priceRange": "$$",
"verified": true
}
]
}
```
#### POST /establishments
Crear nuevo establecimiento
**Body**:
```json
{
"name": "My Hotel",
"type": "hotel",
"description": "Beautiful hotel...",
"location": {
"latitude": 18.4861,
"longitude": -69.9312,
"address": "Street 123"
},
"amenities": ["wifi", "pool"],
"priceRange": "$$",
"images": ["url1", "url2"]
}
```
#### GET /establishments/:id
Obtener detalles de establecimiento
#### PUT /establishments/:id
Actualizar establecimiento
#### DELETE /establishments/:id
Eliminar establecimiento
### Reservas
#### GET /reservations
Listar reservas del usuario
**Respuesta**:
```json
{
"data": [
{
"id": "uuid",
"establishmentId": "uuid",
"type": "hotel",
"checkInDate": "2024-03-15",
"checkOutDate": "2024-03-20",
"guestsCount": 2,
"totalAmount": 500.00,
"status": "confirmed",
"createdAt": "2024-01-10T10:00:00Z"
}
]
}
```
#### POST /reservations
Crear nueva reserva
**Body**:
```json
{
"establishmentId": "uuid",
"type": "hotel",
"checkInDate": "2024-03-15",
"checkOutDate": "2024-03-20",
"guestsCount": 2,
"specialRequests": "Late check-in",
"contactInfo": {
"phone": "+1234567890",
"email": "guest@example.com"
}
}
```
#### PUT /reservations/:id
Actualizar reserva
#### DELETE /reservations/:id
Cancelar reserva
### Reseñas
#### GET /reviews
Obtener reseñas
**Query Parameters**:
- `establishmentId` (string): Filtrar por establecimiento
- `userId` (string): Filtrar por usuario
- `rating` (int): Filtrar por calificación
**Respuesta**:
```json
{
"data": [
{
"id": "uuid",
"establishmentId": "uuid",
"userId": "uuid",
"userName": "John Doe",
"rating": 5,
"comment": "Excellent service!",
"images": ["url1", "url2"],
"createdAt": "2024-01-15T10:00:00Z",
"helpful": 12
}
],
"stats": {
"averageRating": 4.5,
"totalReviews": 150
}
}
```
#### POST /reviews
Crear reseña
**Body**:
```json
{
"establishmentId": "uuid",
"rating": 5,
"comment": "Great experience!",
"images": ["url1", "url2"]
}
```
### Destinos
#### GET /destinations
Listar destinos turísticos
**Respuesta**:
```json
{
"data": [
{
"id": "uuid",
"name": "Punta Cana",
"description": "Beautiful beaches...",
"country": "Dominican Republic",
"images": ["url1", "url2"],
"highlights": ["beaches", "resorts", "nightlife"],
"bestTimeToVisit": "December-April",
"popularActivities": ["snorkeling", "golf"]
}
]
}
```
#### GET /destinations/:id
Detalles de destino
### Lugares
#### GET /places
Listar lugares de interés
**Query Parameters**:
- `destinationId` (string)
- `category` (string): restaurant, attraction, shopping
- `latitude`, `longitude`, `radius`
**Respuesta**:
```json
{
"data": [
{
"id": "uuid",
"name": "Historic Site",
"category": "attraction",
"description": "...",
"location": {...},
"openingHours": {
"monday": "9:00-18:00",
"tuesday": "9:00-18:00"
},
"entryFee": 10.00,
"rating": 4.5
}
]
}
```
### Emergencias (POLITUR)
#### GET /emergency/incidents
Listar incidentes
**Requiere**: Rol de `politur`, `admin` o `super_admin`
**Respuesta**:
```json
{
"incidents": [
{
"id": "uuid",
"type": "medical",
"severity": "high",
"location": {...},
"description": "...",
"status": "active",
"reportedBy": "uuid",
"assignedOfficer": "uuid",
"createdAt": "2024-01-15T10:00:00Z"
}
]
}
```
#### POST /emergency/incidents
Reportar incidente
#### GET /emergency/alerts
Obtener alertas activas
#### POST /emergency/panic-button
Activar botón de pánico
**Body**:
```json
{
"location": {
"latitude": 18.4861,
"longitude": -69.9312
},
"details": "Emergency description"
}
```
### Channel Manager
#### GET /channel-manager/channels
Listar canales conectados
**Respuesta**:
```json
{
"channels": [
{
"id": "booking",
"name": "Booking.com",
"status": "active",
"connected": true,
"listingsCount": 5,
"lastSync": "2024-01-15T10:00:00Z"
}
]
}
```
#### POST /channel-manager/sync
Sincronizar con canales
#### GET /channel-manager/listings
Listar propiedades
#### POST /channel-manager/listings
Crear nueva propiedad
### Comisiones
#### GET /commissions/rules
Obtener reglas de comisiones
**Respuesta**:
```json
{
"rules": [
{
"id": "uuid",
"type": "percentage",
"value": 15,
"entityType": "hotel",
"conditions": {
"minAmount": 100
},
"active": true
}
]
}
```
#### POST /commissions/rules
Crear regla de comisión
#### GET /commissions/payments
Historial de pagos de comisiones
### CRM
#### GET /crm/contacts
Listar contactos
**Respuesta**:
```json
{
"contacts": [
{
"id": "uuid",
"firstName": "John",
"lastName": "Doe",
"email": "john@example.com",
"phone": "+1234567890",
"segment": "VIP Travelers",
"tags": ["repeat_customer"],
"lastInteraction": "2024-01-15T10:00:00Z",
"totalSpent": 5000.00
}
]
}
```
#### POST /crm/contacts
Crear contacto
#### GET /crm/campaigns
Listar campañas
#### POST /crm/campaigns
Crear campaña de marketing
### Analytics
#### GET /analytics/dashboard
Obtener métricas del dashboard
**Respuesta**:
```json
{
"stats": {
"totalUsers": 1500,
"totalRevenue": 156750.50,
"totalBookings": 892,
"activeServices": 89,
"monthlyGrowth": 8.5,
"conversionRate": 3.2
},
"charts": {
"revenueByMonth": [...],
"bookingsByType": [...],
"topDestinations": [...]
}
}
```
#### GET /analytics/reports
Generar reportes personalizados
**Query Parameters**:
- `type` (string): revenue, bookings, users
- `startDate` (date)
- `endDate` (date)
- `format` (string): json, csv, pdf
### Configuración
#### GET /config/apis
Obtener configuración de APIs externas
#### PUT /config/apis
Actualizar configuración de APIs
#### GET /config/parameters
Obtener parámetros del sistema
#### GET /config/integrations
Listar integraciones activas
### Pagos
#### POST /payments/create-intent
Crear intención de pago
**Body**:
```json
{
"amount": 100.00,
"currency": "USD",
"description": "Hotel booking",
"metadata": {
"bookingId": "uuid"
}
}
```
**Respuesta**:
```json
{
"clientSecret": "pi_xxx_secret_xxx",
"paymentIntentId": "pi_xxx"
}
```
#### POST /payments/confirm
Confirmar pago
#### GET /payments/history
Historial de pagos
### Uploads
#### POST /upload
Subir archivo único
**Form Data**:
- `file`: Archivo binario
- `folder`: Carpeta destino (opcional)
**Respuesta**:
```json
{
"url": "https://cdn.karibeo.com/uploads/xxx.jpg",
"fileName": "xxx.jpg",
"size": 102400,
"mimeType": "image/jpeg"
}
```
#### POST /upload/multiple
Subir múltiples archivos
## Códigos de Estado
| Código | Descripción |
|--------|-------------|
| 200 | OK - Solicitud exitosa |
| 201 | Created - Recurso creado |
| 400 | Bad Request - Datos inválidos |
| 401 | Unauthorized - Token inválido o expirado |
| 403 | Forbidden - Sin permisos |
| 404 | Not Found - Recurso no encontrado |
| 409 | Conflict - Conflicto (ej: email duplicado) |
| 422 | Unprocessable Entity - Validación fallida |
| 429 | Too Many Requests - Rate limit excedido |
| 500 | Internal Server Error - Error del servidor |
## Rate Limiting
- **Anónimos**: 100 requests / hora
- **Autenticados**: 1000 requests / hora
- **Admin**: 5000 requests / hora
Headers de respuesta:
```http
X-RateLimit-Limit: 1000
X-RateLimit-Remaining: 999
X-RateLimit-Reset: 1640000000
```
## Paginación
Endpoints que retornan listas soportan paginación:
**Query Parameters**:
- `page`: Número de página (default: 1)
- `limit`: Items por página (default: 10, max: 100)
**Respuesta**:
```json
{
"data": [...],
"pagination": {
"total": 250,
"page": 1,
"pages": 25,
"limit": 10
}
}
```
## Filtros y Ordenamiento
**Query Parameters**:
- `sort`: Campo para ordenar
- `order`: asc o desc
- `filter[campo]`: Filtrar por campo
Ejemplo:
```
GET /establishments?sort=rating&order=desc&filter[type]=hotel
```
## Webhooks
Para eventos en tiempo real, el sistema soporta webhooks:
```http
POST /webhooks/configure
Content-Type: application/json
{
"url": "https://your-domain.com/webhook",
"events": ["booking.created", "payment.completed"],
"secret": "your_webhook_secret"
}
```
Eventos disponibles:
- `booking.created`
- `booking.confirmed`
- `booking.cancelled`
- `payment.completed`
- `payment.failed`
- `review.created`
- `user.registered`
## SDKs y Libraries
### JavaScript/TypeScript
```bash
npm install @karibeo/sdk
```
```typescript
import Karibeo from '@karibeo/sdk';
const client = new Karibeo({
apiKey: 'your_api_key',
baseUrl: 'https://karibeo.lesoluciones.net:8443/api/v1'
});
const bookings = await client.reservations.list();
```
### Python
```bash
pip install karibeo
```
```python
from karibeo import Client
client = Client(api_key='your_api_key')
bookings = client.reservations.list()
```
## Postman Collection
Importar colección completa:
```
https://www.postman.com/karibeo/workspace/karibeo-api
```
## Testing
### Credenciales de Testing
```
Email: test@karibeo.com
Password: Test123!
```
### Endpoints de Testing
```
https://karibeo-staging.lesoluciones.net:8443/api/v1
```
---
**Soporte**: api-support@karibeo.com
**Última actualización**: 2025-01-12

428
docs/ARCHITECTURE.md Normal file
View File

@@ -0,0 +1,428 @@
# 🏗️ Arquitectura del Sistema Karibeo
## Visión General
Karibeo está construido siguiendo una arquitectura modular de frontend con separación clara de responsabilidades y patrones de diseño modernos.
## Diagrama de Arquitectura
```
┌─────────────────────────────────────────────────────────────┐
│ PRESENTACIÓN │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ Tourist │ │ Hotel │ │Restaurant│ │ Commerce │ │
│ │ App │ │ POS │ │ POS │ │ POS │ │
│ └──────────┘ └──────────┘ └──────────┘ └──────────┘ │
│ ┌──────────────────────────────────────────────────────┐ │
│ │ Admin Dashboard & Control Panel │ │
│ └──────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ CAPA DE NEGOCIO │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ Contexts │ │ Hooks │ │ Services │ │Validation│ │
│ └──────────┘ └──────────┘ └──────────┘ └──────────┘ │
└─────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ CAPA DE DATOS │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ API │ │ Cache │ │ LocalSt │ │ State │ │
│ │ Client │ │(TanStack)│ │ (tokens) │ │Management│ │
│ └──────────┘ └──────────┘ └──────────┘ └──────────┘ │
└─────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ BACKEND API REST │
│ https://karibeo.lesoluciones.net:8443/api/v1 │
└─────────────────────────────────────────────────────────────┘
```
## Capas de la Aplicación
### 1. Capa de Presentación (UI)
#### Componentes Principales
```
src/components/
├── ui/ # Componentes base (shadcn)
├── admin/ # Componentes administrativos
├── hotel/ # Componentes hoteleros
├── restaurant/ # Componentes de restaurante
├── tourist/ # Componentes para turistas
└── shared/ # Componentes compartidos
```
**Responsabilidades**:
- Renderizado de UI
- Interacción con el usuario
- Delegación de lógica a hooks y contexts
**Patrones Utilizados**:
- **Composition Pattern**: Composición de componentes pequeños
- **Compound Components**: Para componentes complejos como Forms
- **Render Props**: Para lógica reutilizable
### 2. Capa de Lógica de Negocio
#### React Contexts
```typescript
src/contexts/
├── AuthContext.tsx # Autenticación y sesión
├── CartContext.tsx # Carrito de compras
├── CurrencyContext.tsx # Gestión de monedas
└── LanguageContext.tsx # Internacionalización
```
**Responsabilidades**:
- Estado global de la aplicación
- Lógica de negocio compartida
- Sincronización de estado
#### Custom Hooks
```typescript
src/hooks/
├── useAdminData.ts # Datos de administración
├── useBooking.ts # Lógica de reservas
├── useChannelManager.ts # Channel manager
├── useRolesPermissions.ts # Roles y permisos
└── useEmergencyData.ts # Sistema de emergencias
```
**Responsabilidades**:
- Encapsular lógica reutilizable
- Gestión de efectos secundarios
- Integración con APIs
### 3. Capa de Servicios (API)
#### Servicios API
```typescript
src/services/
├── adminApi.ts # API de administración
├── emergencyApi.ts # API de emergencias
├── bookmarkApi.ts # API de favoritos
├── chatApi.ts # API de mensajería
├── configApi.ts # API de configuración
├── paymentService.ts # Servicio de pagos
├── reviewService.ts # Servicio de reseñas
└── tourismService.ts # Servicios turísticos
```
**ApiClient Class**:
```typescript
class ApiClient {
- baseUrl: string
- request<T>(endpoint, options): Promise<T>
+ get<T>(endpoint): Promise<T>
+ post<T>(endpoint, data): Promise<T>
+ put<T>(endpoint, data): Promise<T>
+ delete<T>(endpoint): Promise<T>
+ postForm<T>(endpoint, data): Promise<T>
}
```
**Características**:
- Interceptores de request/response
- Refresh token automático
- Manejo centralizado de errores
- Timeout configurable (30s)
- Retry logic para 401 errors
### 4. Capa de Validación
```typescript
src/lib/validation.ts
```
**Schemas Zod**:
- `loginSchema`: Validación de login
- `registerSchema`: Validación de registro
- `profileUpdateSchema`: Actualización de perfil
- `contactFormSchema`: Formularios de contacto
- `reviewSchema`: Validación de reseñas
- `searchSchema`: Validación de búsquedas
## Flujo de Datos
### Flujo de Autenticación
```
┌─────────┐ ┌──────────────┐ ┌────────────┐
│ SignIn │───────>│ AuthContext │───────>│ adminApi │
│ Form │ submit │ .login() │ POST │ /auth/login│
└─────────┘ └──────────────┘ └────────────┘
│ │
│ ▼
│ ┌────────────┐
│<───────────────│ Backend │
│ JWT Token │ API │
│ └────────────┘
┌─────────────┐
│ localStorage │
│ - token │
│ - user data │
└─────────────┘
```
### Flujo de Reservas
```
┌──────────┐ ┌──────────────┐ ┌────────────┐
│ Booking │───────>│ useBooking │───────>│ bookingApi │
│ Form │ submit │ hook │ POST │/reservations│
└──────────┘ └──────────────┘ └────────────┘
│ │
│ ▼
│ ┌────────────┐
│<───────────────│ Backend │
│ Confirmation │ API │
│ └────────────┘
┌─────────────┐
│ TanStack │
│ Query │
│ Cache │
└─────────────┘
```
## Gestión de Estado
### Estado Local (Component State)
```typescript
const [formData, setFormData] = useState({...});
```
**Uso**: Estado temporal de componentes individuales
### Estado Global (Context)
```typescript
const { user, login, logout } = useAuth();
```
**Uso**: Estado compartido entre múltiples componentes
### Estado del Servidor (TanStack Query)
```typescript
const { data, isLoading } = useQuery({
queryKey: ['users'],
queryFn: () => adminApi.getAllUsers()
});
```
**Uso**: Cache y sincronización de datos del servidor
## Patrones de Diseño
### 1. Container/Presentational Pattern
```typescript
// Container (lógica)
const UsersContainer = () => {
const { users, loading } = useAdminData();
return <UsersList users={users} loading={loading} />;
};
// Presentational (UI)
const UsersList = ({ users, loading }) => {
if (loading) return <Spinner />;
return <div>{users.map(...)}</div>;
};
```
### 2. Compound Components Pattern
```typescript
<Form>
<Form.Field name="email">
<Form.Label>Email</Form.Label>
<Form.Input type="email" />
<Form.Error />
</Form.Field>
</Form>
```
### 3. Custom Hook Pattern
```typescript
const useBooking = () => {
const [bookings, setBookings] = useState([]);
const createBooking = async (data) => {...};
return { bookings, createBooking };
};
```
### 4. Higher-Order Component (HOC) Pattern
```typescript
const ProtectedRoute = ({ children }) => {
const { isAuthenticated } = useAuth();
return isAuthenticated ? children : <Navigate to="/login" />;
};
```
## Estrategia de Routing
### Estructura de Rutas
```
/ # Landing page
/sign-in # Login
/sign-up # Registro
/explore # Exploración pública
/listing-details/:id # Detalles de listado
/dashboard # Dashboard principal (turista)
/dashboard/admin # Panel de administración
/dashboard/admin?tab=... # Tabs de administración
/dashboard/hotel/* # Rutas hoteleras
/dashboard/restaurant/* # Rutas de restaurante
/dashboard/commerce/* # Rutas de comercio
/dashboard/roles-permissions # Gestión de roles
/dashboard/crm/* # CRM
/dashboard/commissions/* # Comisiones
```
### Protección de Rutas
```typescript
<Route element={<ProtectedRoute />}>
<Route path="/dashboard" element={<Dashboard />} />
</Route>
```
## Optimizaciones de Performance
### Code Splitting
```typescript
const AdminDashboard = lazy(() => import('./pages/AdminDashboard'));
```
### Memoization
```typescript
const MemoizedComponent = memo(ExpensiveComponent);
const memoizedValue = useMemo(() => computeExpensiveValue(a, b), [a, b]);
const memoizedCallback = useCallback(() => doSomething(a, b), [a, b]);
```
### Virtual Scrolling
Para listas largas de datos (usuarios, reservas, etc.)
### Image Optimization
- Lazy loading con `loading="lazy"`
- Formatos modernos (WebP)
- Responsive images
## Seguridad
### Medidas Implementadas
1. **Validación de Inputs**: Todos los formularios usan Zod
2. **Sanitización**: Prevención de XSS
3. **HTTPS Only**: Todas las comunicaciones encriptadas
4. **JWT Tokens**: Autenticación stateless
5. **Refresh Tokens**: Rotación automática
6. **CORS**: Configurado en backend
7. **Rate Limiting**: En endpoints críticos
### Consideraciones de Seguridad
⚠️ **IMPORTANTE**: La verificación de roles actual es client-side. Para producción se requiere:
- Tabla `user_roles` separada en la base de datos
- Verificación de permisos en cada endpoint del backend
- Row Level Security (RLS) policies
## Escalabilidad
### Preparación para Escala
1. **Modularización**: Código organizado en módulos independientes
2. **Lazy Loading**: Carga diferida de componentes
3. **API Caching**: TanStack Query con estrategias de cache
4. **CDN Ready**: Assets estáticos optimizados
5. **Micro-frontends Ready**: Arquitectura permite separación futura
### Métricas de Performance
- **First Contentful Paint**: < 1.5s
- **Time to Interactive**: < 3.5s
- **Bundle Size**: ~500KB (gzipped)
## Testing Strategy
### Unit Tests
```typescript
describe('useAuth', () => {
it('should login successfully', async () => {
// test implementation
});
});
```
### Integration Tests
```typescript
describe('BookingFlow', () => {
it('should complete booking', async () => {
// test implementation
});
});
```
### E2E Tests (Cypress/Playwright)
```typescript
describe('Complete User Journey', () => {
it('should allow user to make a reservation', () => {
// test implementation
});
});
```
## Monitoring y Observabilidad
### Logs
- Console logs en desarrollo
- Sentry/LogRocket en producción
### Analytics
- Google Analytics 4
- Custom events tracking
- User behavior analysis
### Error Tracking
- Error boundaries en componentes críticos
- Reportes automáticos a Sentry
## Deployment Pipeline
```
┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐
│ Git │───>│ Build │───>│ Test │───>│ Deploy │
│ Commit │ │ (Vite) │ │ (Jest) │ │(Lovable) │
└──────────┘ └──────────┘ └──────────┘ └──────────┘
```
## Roadmap Técnico
### Corto Plazo (3 meses)
- [ ] Implementar testing completo
- [ ] Optimizar bundle size
- [ ] Mejorar accesibilidad (WCAG 2.1 AA)
- [ ] Implementar PWA completa
### Medio Plazo (6 meses)
- [ ] Migrar a Server Components (React 19)
- [ ] Implementar GraphQL
- [ ] WebSockets para real-time
- [ ] Backend propio (Node.js/NestJS)
### Largo Plazo (12 meses)
- [ ] Micro-frontends architecture
- [ ] Mobile apps (React Native)
- [ ] AI/ML integration
- [ ] Blockchain para pagos
---
**Última actualización**: 2025-01-12
**Mantenedores**: Equipo de Desarrollo Karibeo

608
docs/DEVELOPMENT.md Normal file
View File

@@ -0,0 +1,608 @@
# 🛠️ Guía de Desarrollo - Karibeo
## Configuración del Entorno
### Requisitos del Sistema
- **Node.js**: >= 18.0.0 (recomendado 20.x LTS)
- **npm**: >= 9.0.0 o **yarn**: >= 1.22.0
- **Git**: >= 2.30.0
- **Editor**: VSCode (recomendado) con extensiones
### Extensiones VSCode Recomendadas
```json
{
"recommendations": [
"dbaeumer.vscode-eslint",
"esbenp.prettier-vscode",
"bradlc.vscode-tailwindcss",
"formulahendry.auto-rename-tag",
"christian-kohler.path-intellisense",
"dsznajder.es7-react-js-snippets",
"mikestead.dotenv"
]
}
```
## Setup Inicial
### 1. Clonar el Repositorio
```bash
git clone https://github.com/tu-usuario/karibeo.git
cd karibeo
```
### 2. Instalar Dependencias
```bash
npm install
# o
yarn install
```
### 3. Configurar Variables de Entorno
Crear archivo `.env` en la raíz:
```env
# API Backend
VITE_API_BASE_URL=https://karibeo.lesoluciones.net:8443/api/v1
# Google Maps
VITE_GOOGLE_MAPS_API_KEY=your_google_maps_api_key
# Stripe (Pagos)
VITE_STRIPE_PUBLIC_KEY=pk_test_...
# Analytics (Opcional)
VITE_GA_ID=G-XXXXXXXXXX
# Sentry (Error Tracking - Opcional)
VITE_SENTRY_DSN=https://...@sentry.io/...
# Feature Flags (Opcional)
VITE_ENABLE_ANALYTICS=true
VITE_ENABLE_PWA=false
```
### 4. Iniciar Servidor de Desarrollo
```bash
npm run dev
```
La aplicación estará disponible en: `http://localhost:5173`
## Estructura de Carpetas Detallada
```
karibeo/
├── .vscode/ # Configuración de VSCode
│ ├── settings.json # Configuraciones del workspace
│ └── extensions.json # Extensiones recomendadas
├── docs/ # Documentación
│ ├── API.md # Documentación de API
│ ├── ARCHITECTURE.md # Arquitectura del sistema
│ └── DEVELOPMENT.md # Esta guía
├── public/ # Assets estáticos
│ ├── favicon.ico
│ ├── manifest.json # PWA manifest
│ └── robots.txt
├── src/
│ ├── assets/ # Recursos (imágenes, fonts)
│ │ ├── images/
│ │ └── fonts/
│ │
│ ├── components/ # Componentes React
│ │ ├── ui/ # Componentes base (shadcn)
│ │ │ ├── button.tsx
│ │ │ ├── input.tsx
│ │ │ └── ...
│ │ │
│ │ ├── admin/ # Componentes de admin
│ │ │ ├── ConfigTab.tsx
│ │ │ ├── UsersTab.tsx
│ │ │ └── ...
│ │ │
│ │ ├── hotel/ # Componentes hoteleros
│ │ ├── restaurant/ # Componentes de restaurante
│ │ ├── roles/ # Sistema de roles
│ │ ├── security/ # Componentes de seguridad
│ │ └── shared/ # Componentes compartidos
│ │
│ ├── contexts/ # React Contexts
│ │ ├── AuthContext.tsx
│ │ ├── CartContext.tsx
│ │ ├── CurrencyContext.tsx
│ │ └── LanguageContext.tsx
│ │
│ ├── hooks/ # Custom Hooks
│ │ ├── useAdminData.ts
│ │ ├── useBooking.ts
│ │ ├── useChannelManager.ts
│ │ └── ...
│ │
│ ├── lib/ # Utilidades y helpers
│ │ ├── utils.ts # Funciones utilitarias
│ │ └── validation.ts # Schemas de validación
│ │
│ ├── pages/ # Páginas/Rutas
│ │ ├── dashboard/ # Panel de control
│ │ │ ├── AdminDashboard.tsx
│ │ │ ├── Dashboard.tsx
│ │ │ └── ...
│ │ │
│ │ ├── Index.tsx # Landing page
│ │ ├── SignIn.tsx # Login
│ │ └── SignUp.tsx # Registro
│ │
│ ├── services/ # Servicios API
│ │ ├── adminApi.ts
│ │ ├── emergencyApi.ts
│ │ └── ...
│ │
│ ├── types/ # TypeScript types
│ │ ├── index.ts
│ │ └── roles.ts
│ │
│ ├── i18n/ # Internacionalización
│ │ ├── en.ts
│ │ └── es.ts
│ │
│ ├── App.tsx # Componente principal
│ ├── main.tsx # Entry point
│ └── index.css # Estilos globales
├── .eslintrc.js # Configuración ESLint
├── .prettierrc # Configuración Prettier
├── tailwind.config.ts # Configuración Tailwind
├── tsconfig.json # Configuración TypeScript
├── vite.config.ts # Configuración Vite
└── package.json # Dependencias y scripts
```
## Convenciones de Código
### Nomenclatura
#### Archivos
```typescript
// PascalCase para componentes
UserProfile.tsx
AdminDashboard.tsx
// camelCase para utilidades y hooks
useAuth.ts
formatCurrency.ts
// kebab-case para estilos
user-profile.css
```
#### Variables y Funciones
```typescript
// camelCase para variables y funciones
const userName = "John";
function getUserData() {}
// PascalCase para componentes y clases
const UserCard = () => {};
class ApiClient {}
// UPPER_SNAKE_CASE para constantes
const MAX_RETRY_ATTEMPTS = 3;
const API_BASE_URL = "https://...";
```
#### Interfaces y Types
```typescript
// PascalCase con prefijo "I" opcional
interface UserProfile {}
interface IUserProfile {} // alternativa
type UserRole = 'admin' | 'user';
```
### Formato de Código
#### Imports
```typescript
// 1. Imports de librerías externas
import React, { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
// 2. Imports de componentes UI
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
// 3. Imports de componentes propios
import { UserCard } from '@/components/UserCard';
// 4. Imports de hooks y contexts
import { useAuth } from '@/contexts/AuthContext';
import { useAdminData } from '@/hooks/useAdminData';
// 5. Imports de utilidades y tipos
import { cn } from '@/lib/utils';
import type { User } from '@/types';
// 6. Imports de assets
import logo from '@/assets/logo.png';
// 7. Imports de estilos
import './styles.css';
```
#### Componentes Funcionales
```typescript
// Buena práctica
interface UserCardProps {
user: User;
onEdit?: (id: string) => void;
className?: string;
}
export const UserCard: React.FC<UserCardProps> = ({
user,
onEdit,
className
}) => {
const [isExpanded, setIsExpanded] = useState(false);
useEffect(() => {
// Effect logic
}, []);
const handleClick = () => {
setIsExpanded(!isExpanded);
};
return (
<div className={cn("card", className)}>
{/* JSX */}
</div>
);
};
```
### TypeScript
#### Tipado Estricto
```typescript
// ✅ Bueno
interface User {
id: string;
name: string;
email: string;
}
const getUser = (id: string): Promise<User> => {
return api.get<User>(`/users/${id}`);
};
// ❌ Evitar
const getUser = (id: any): any => {
return api.get(`/users/${id}`);
};
```
#### Usar Interfaces sobre Types
```typescript
// ✅ Preferido para objetos
interface UserProfile {
name: string;
age: number;
}
// ✅ Usar type para unions, intersections
type UserRole = 'admin' | 'user' | 'guest';
type ExtendedUser = UserProfile & { role: UserRole };
```
### React Patterns
#### Hooks
```typescript
// Custom hook
export const useUser = (userId: string) => {
const [user, setUser] = useState<User | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
useEffect(() => {
fetchUser(userId)
.then(setUser)
.catch(setError)
.finally(() => setLoading(false));
}, [userId]);
return { user, loading, error };
};
```
#### Memoization
```typescript
// useMemo para cálculos costosos
const expensiveValue = useMemo(() => {
return computeExpensiveValue(a, b);
}, [a, b]);
// useCallback para funciones
const handleSubmit = useCallback((data: FormData) => {
submitForm(data);
}, []);
// memo para componentes
export const UserCard = memo(({ user }: Props) => {
return <div>{user.name}</div>;
});
```
### Tailwind CSS
#### Ordenamiento de Clases
```tsx
// Orden recomendado:
// 1. Layout (display, position)
// 2. Box model (width, height, margin, padding)
// 3. Typography
// 4. Visual (background, border, shadow)
// 5. Misc (cursor, transform, transition)
<div className="
flex items-center justify-between
w-full h-20 p-4
text-lg font-semibold
bg-white border border-gray-200 rounded-lg shadow-md
hover:shadow-lg transition-shadow
">
Content
</div>
```
#### Usar cn() para Clases Condicionales
```tsx
import { cn } from '@/lib/utils';
<Button
className={cn(
"base-class",
isActive && "active-class",
isDisabled && "disabled-class",
customClass
)}
/>
```
## Testing
### Jest + React Testing Library
#### Setup
```bash
npm install --save-dev @testing-library/react @testing-library/jest-dom jest
```
#### Escribir Tests
```typescript
// UserCard.test.tsx
import { render, screen, fireEvent } from '@testing-library/react';
import { UserCard } from './UserCard';
describe('UserCard', () => {
const mockUser = {
id: '1',
name: 'John Doe',
email: 'john@example.com'
};
it('renders user information', () => {
render(<UserCard user={mockUser} />);
expect(screen.getByText('John Doe')).toBeInTheDocument();
});
it('calls onEdit when edit button is clicked', () => {
const onEdit = jest.fn();
render(<UserCard user={mockUser} onEdit={onEdit} />);
fireEvent.click(screen.getByRole('button', { name: /edit/i }));
expect(onEdit).toHaveBeenCalledWith('1');
});
});
```
### Ejecutar Tests
```bash
npm run test # Ejecutar todos los tests
npm run test:watch # Watch mode
npm run test:coverage # Con coverage
```
## Debugging
### React Developer Tools
Instalar extensión de navegador: React Developer Tools
### VSCode Debugging
Crear `.vscode/launch.json`:
```json
{
"version": "0.2.0",
"configurations": [
{
"type": "chrome",
"request": "launch",
"name": "Launch Chrome",
"url": "http://localhost:5173",
"webRoot": "${workspaceFolder}/src"
}
]
}
```
### Console Logs Útiles
```typescript
// Debug con contexto
console.log('🔍 User data:', user);
console.error('❌ Error:', error);
console.warn('⚠️ Warning:', warning);
// Grupos
console.group('API Request');
console.log('URL:', url);
console.log('Method:', method);
console.groupEnd();
// Tiempo de ejecución
console.time('fetchData');
await fetchData();
console.timeEnd('fetchData');
```
## Performance
### Optimizaciones
#### Code Splitting
```typescript
// Lazy loading de componentes
const AdminDashboard = lazy(() => import('./pages/AdminDashboard'));
<Suspense fallback={<LoadingSpinner />}>
<AdminDashboard />
</Suspense>
```
#### Bundle Analysis
```bash
npm run build
npm run analyze
```
#### Lighthouse CI
```bash
npm install -g @lhci/cli
lhci autorun
```
## Git Workflow
### Branches
```
main # Producción
develop # Desarrollo
feature/xxx # Nuevas características
bugfix/xxx # Corrección de bugs
hotfix/xxx # Hotfixes urgentes
```
### Commits
Usar convención de commits semánticos:
```bash
feat: add user authentication
fix: resolve booking calculation bug
docs: update API documentation
style: format code with prettier
refactor: reorganize admin components
test: add tests for UserCard component
chore: update dependencies
```
### Pull Requests
1. Crear branch desde `develop`
2. Hacer cambios y commits
3. Push al repositorio remoto
4. Crear PR hacia `develop`
5. Code review
6. Merge después de aprobación
## Build y Deploy
### Build Local
```bash
npm run build
```
Archivos generados en `/dist`
### Preview Build
```bash
npm run preview
```
### Deploy a Lovable
1. Commit y push cambios
2. Ir a proyecto en Lovable
3. Cambios se sincronizarán automáticamente
### Deploy Manual
```bash
# Vercel
vercel deploy
# Netlify
netlify deploy --prod
# AWS S3
aws s3 sync dist/ s3://your-bucket/
```
## Troubleshooting
### Problemas Comunes
#### Puerto 5173 en uso
```bash
# Cambiar puerto en vite.config.ts
export default defineConfig({
server: {
port: 3000
}
});
```
#### Problemas con node_modules
```bash
rm -rf node_modules package-lock.json
npm install
```
#### Errores de TypeScript
```bash
# Limpiar cache
rm -rf node_modules/.vite
npm run dev
```
#### Build falla
```bash
# Verificar errores
npm run build 2>&1 | tee build.log
# Limpiar y rebuild
rm -rf dist
npm run build
```
## Resources
### Documentación Oficial
- [React](https://react.dev/)
- [TypeScript](https://www.typescriptlang.org/docs/)
- [Vite](https://vitejs.dev/)
- [Tailwind CSS](https://tailwindcss.com/docs)
- [shadcn/ui](https://ui.shadcn.com/)
### Comunidad
- [Karibeo Discord](#)
- [GitHub Discussions](#)
- [Stack Overflow](https://stackoverflow.com/questions/tagged/karibeo)
---
**Última actualización**: 2025-01-12

324
docs/RESPONSIVE.md Normal file
View File

@@ -0,0 +1,324 @@
# Responsive Design Documentation
## Overview
Karibeo está completamente optimizado para funcionar en dispositivos móviles, tablets y escritorio. Esta documentación detalla las estrategias de diseño responsivo implementadas.
## Breakpoints
El proyecto utiliza breakpoints estándar de Tailwind CSS:
```typescript
const BREAKPOINTS = {
mobile: 768, // < 768px
tablet: 1024, // 768px - 1023px
desktop: 1280, // 1024px - 1279px
largeDesktop: 1280+ // >= 1280px
}
```
## Hooks Personalizados
### useResponsive
Hook para detectar el breakpoint actual:
```typescript
import { useResponsive } from '@/hooks/useResponsive';
const { isMobile, isTablet, isDesktop, width } = useResponsive();
```
### useOrientation
Hook para detectar orientación del dispositivo:
```typescript
import { useOrientation } from '@/hooks/useResponsive';
const orientation = useOrientation(); // 'portrait' | 'landscape'
```
## Frontend Responsive
### Dashboard Layout
- **Sidebar**: Oculto en móvil, colapsable en desktop
- **Top Navigation**: Componentes adaptados según tamaño de pantalla
- **Content Area**: Padding responsive (3 en móvil, 6 en desktop)
```tsx
// Ejemplo de uso
<div className="p-3 md:p-6">
{/* Content adapts padding based on screen size */}
</div>
```
### Grid Layouts
Todos los grids utilizan configuración responsive:
```tsx
// ExploreSection
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 md:gap-6">
{/* Cards automatically adjust */}
</div>
// PlacesSection
<div className="grid grid-cols-1 md:grid-cols-12 gap-0">
{/* Stacks vertically on mobile, side-by-side on desktop */}
</div>
```
### Typography
Tamaños de texto adaptados:
```tsx
<h1 className="text-lg md:text-2xl font-bold">
{/* Smaller on mobile, larger on desktop */}
</h1>
```
### Images
Alturas responsive en tarjetas:
```tsx
<div className="h-[350px] sm:h-[400px] lg:h-[458px]">
{/* Image height scales with screen size */}
</div>
```
## Backend Responsive
### Middleware API
El archivo `src/middleware/responsiveAPI.ts` proporciona funciones para optimizar las respuestas del backend:
#### detectDevice
Detecta tipo de dispositivo desde User-Agent:
```typescript
import { detectDevice } from '@/middleware/responsiveAPI';
const deviceType = detectDevice(userAgent, width);
// Returns: 'mobile' | 'tablet' | 'desktop'
```
#### optimizePayload
Optimiza payload para dispositivos móviles:
```typescript
import { optimizePayload } from '@/middleware/responsiveAPI';
const optimized = optimizePayload(data, deviceType);
// Reduces image quality, limits array sizes for mobile
```
#### addDeviceHeaders
Añade headers de dispositivo a requests:
```typescript
import { addDeviceHeaders } from '@/middleware/responsiveAPI';
const headers = addDeviceHeaders();
// Adds: X-Device-Type, X-Screen-Width, X-User-Agent
```
#### getAdaptivePagination
Paginación adaptativa según dispositivo:
```typescript
import { getAdaptivePagination } from '@/middleware/responsiveAPI';
const { limit, offset } = getAdaptivePagination(deviceType);
// mobile: 10, tablet: 20, desktop: 50
```
## Utilities
### responsive.ts
Funciones útiles para comportamiento responsive:
```typescript
import {
responsiveClass,
getResponsiveValue,
isTouchDevice,
formatTextForMobile
} from '@/utils/responsive';
// Clases responsive
const classes = responsiveClass('base', 'mobile-class', 'tablet-class', 'desktop-class');
// Valores responsive
const value = getResponsiveValue(10, 20, 50, width);
// Detectar touch
const isTouch = isTouchDevice();
// Formatear texto
const text = formatTextForMobile(longText, 100, isMobile);
```
## Mejores Prácticas
### 1. Mobile-First
Siempre diseñar primero para móvil:
```tsx
// ✅ Correcto
<div className="p-3 md:p-6">
// ❌ Incorrecto
<div className="p-6 md:p-3">
```
### 2. Ocultar Elementos
Usar clases de Tailwind para ocultar:
```tsx
// Oculto en móvil
<div className="hidden md:block">
// Solo visible en móvil
<div className="block md:hidden">
```
### 3. Espaciado Responsive
Adaptar gaps, padding y margin:
```tsx
<div className="gap-2 md:gap-4 lg:gap-6">
{/* Spacing increases with screen size */}
</div>
```
### 4. Touch Targets
Asegurar áreas táctiles suficientes en móvil (mínimo 44x44px):
```tsx
<button className="w-10 h-10 md:w-12 md:h-12">
{/* Adequate touch area on mobile */}
</button>
```
### 5. Overflow Handling
Prevenir overflow horizontal:
```tsx
<div className="overflow-x-auto">
<div className="min-w-max">
{/* Wide content scrolls horizontally */}
</div>
</div>
```
## Testing
### Breakpoints de Prueba
Probar en estos anchos mínimos:
- **Mobile**: 320px, 375px, 414px
- **Tablet**: 768px, 834px
- **Desktop**: 1024px, 1280px, 1920px
### Orientación
Probar tanto portrait como landscape en móvil/tablet.
### Touch Events
Verificar que todos los elementos interactivos funcionen con touch.
## Rendimiento
### Lazy Loading
Implementar lazy loading para imágenes:
```tsx
<img loading="lazy" src={image} alt={alt} />
```
### Code Splitting
React.lazy para componentes pesados:
```typescript
const HeavyComponent = lazy(() => import('./HeavyComponent'));
```
### Responsive Images
Usar srcset para diferentes resoluciones:
```tsx
<img
src="image-mobile.jpg"
srcSet="image-mobile.jpg 375w, image-tablet.jpg 768w, image-desktop.jpg 1920w"
sizes="(max-width: 768px) 100vw, (max-width: 1024px) 50vw, 33vw"
/>
```
## Accesibilidad
### Viewport Meta Tag
Ya incluido en index.html:
```html
<meta name="viewport" content="width=device-width, initial-scale=1.0">
```
### Focus States
Asegurar estados de focus visibles:
```tsx
<button className="focus:ring-2 focus:ring-primary focus:outline-none">
```
### ARIA Labels
Usar labels descriptivos para lectores de pantalla:
```tsx
<button aria-label="Open menu">
<Menu />
</button>
```
## Mantenimiento
### Auditorías Regulares
- Ejecutar Lighthouse para móvil y desktop
- Verificar responsive en DevTools
- Probar en dispositivos reales
### Actualización de Breakpoints
Si se necesitan breakpoints custom, actualizar en:
1. `tailwind.config.ts`
2. `src/hooks/useResponsive.tsx`
3. Documentación
## Recursos
- [Tailwind Responsive Design](https://tailwindcss.com/docs/responsive-design)
- [MDN Media Queries](https://developer.mozilla.org/en-US/docs/Web/CSS/Media_Queries)
- [Web.dev Responsive Images](https://web.dev/responsive-images/)

588
docs/USER-GUIDE.md Normal file
View File

@@ -0,0 +1,588 @@
# 📖 Guía de Usuario - Karibeo
## Introducción
Bienvenido a Karibeo, tu plataforma integral para gestión turística y comercial. Esta guía te ayudará a aprovechar al máximo todas las funcionalidades del sistema.
## Índice
1. [Primeros Pasos](#primeros-pasos)
2. [Para Turistas](#para-turistas)
3. [Para Hoteles](#para-hoteles)
4. [Para Restaurantes](#para-restaurantes)
5. [Para Comercios](#para-comercios)
6. [Panel de Administración](#panel-de-administración)
7. [Preguntas Frecuentes](#preguntas-frecuentes)
---
## Primeros Pasos
### Registro de Cuenta
1. **Acceder a la Plataforma**
- Visita [karibeo.com](https://karibeo.com)
- Click en "Registrarse" en la esquina superior derecha
2. **Completar Formulario**
- Nombre completo
- Email válido
- Contraseña segura (mínimo 8 caracteres, incluir mayúsculas, minúsculas y números)
- Tipo de cuenta: Turista o Comercio
3. **Verificar Email**
- Revisa tu bandeja de entrada
- Click en el enlace de verificación
- Tu cuenta estará activa
### Iniciar Sesión
1. Click en "Iniciar Sesión"
2. Ingresa tu email y contraseña
3. Opcional: Marca "Recordarme" para mantener la sesión
### Recuperar Contraseña
1. En la página de login, click en "Olvidé mi contraseña"
2. Ingresa tu email
3. Revisa tu correo y sigue las instrucciones
---
## Para Turistas
### Explorar Destinos
#### Buscar Destinos
1. Desde la página principal, usa el buscador
2. Filtra por:
- País/Región
- Tipo de actividad
- Rango de precios
- Calificación
#### Ver Detalles
- Click en cualquier destino para ver:
- Descripción completa
- Galería de fotos
- Actividades populares
- Mejor época para visitar
- Reseñas de otros viajeros
### Hacer Reservas
#### Hoteles
1. **Buscar Hotel**
- Ingresa ciudad o nombre del hotel
- Selecciona fechas (check-in / check-out)
- Número de huéspedes
2. **Comparar Opciones**
- Ver precios y disponibilidad
- Comparar amenidades
- Leer reseñas
3. **Reservar**
- Selecciona tipo de habitación
- Ingresa datos de contacto
- Solicitudes especiales (opcional)
- Proceder al pago
#### Restaurantes
1. **Buscar Restaurante**
- Por ubicación o nombre
- Filtrar por tipo de cocina
- Ver menús y precios
2. **Hacer Reserva**
- Selecciona fecha y hora
- Número de comensales
- Preferencias especiales
### Gestionar Reservas
Accede a "Mi Dashboard" → "Reservas"
#### Ver Reservas
- Todas tus reservas en un solo lugar
- Estado: Confirmada, Pendiente, Completada
- Detalles completos de cada reserva
#### Modificar Reserva
- Click en la reserva
- "Modificar" → Cambiar fechas o detalles
- Confirmar cambios
#### Cancelar Reserva
- Ver política de cancelación
- Solicitar cancelación
- Reembolso según políticas
### Billetera Digital
#### Cargar Saldo
1. Ve a "Billetera"
2. "Agregar Fondos"
3. Ingresa monto
4. Selecciona método de pago
5. Confirmar transacción
#### Ver Transacciones
- Historial completo
- Exportar a PDF/Excel
- Filtrar por fecha o tipo
### Reseñas
#### Escribir Reseña
1. Ve a "Mis Reservas" → Reservas completadas
2. Click en "Escribir Reseña"
3. Calificación (1-5 estrellas)
4. Comentario detallado
5. Subir fotos (opcional)
6. Publicar
#### Gestionar Reseñas
- Ver tus reseñas publicadas
- Editar o eliminar reseñas
- Respuestas de negocios
### Mapa Interactivo
#### Usar el Mapa
- Activar geolocalización
- Ver servicios cercanos
- Filtrar por categoría
- Obtener direcciones
---
## Para Hoteles
### Dashboard Hotelero
Accede a "Dashboard" → "Hotel Management"
### Gestión de Habitaciones
#### Agregar Nueva Habitación
1. "Habitaciones" → "Agregar"
2. Completar información:
- Tipo de habitación (Sencilla, Doble, Suite)
- Precio por noche
- Capacidad
- Amenidades
- Fotos de alta calidad
3. Guardar
#### Gestionar Inventario
- Ver disponibilidad en calendario
- Bloquear fechas (mantenimiento, eventos)
- Ajustar precios por temporada
### Sistema de Reservas
#### Ver Reservas
- Calendario de reservas
- Lista detallada
- Filtrar por estado o fecha
#### Procesar Reserva
1. Nueva reserva aparece como "Pendiente"
2. Revisar detalles
3. Confirmar o rechazar
4. Notificación automática al cliente
#### Check-in / Check-out
1. Buscar reserva por nombre o ID
2. Verificar identidad del huésped
3. Procesar check-in
4. Al finalizar estadía: Check-out
5. Solicitar reseña
### Room Service
#### Gestionar Pedidos
1. Ver pedidos activos
2. Estado: Recibido → En preparación → Entregado
3. Asignar a personal
4. Marcar como completado
### Gestión de Personal
#### Agregar Empleado
1. "Personal" → "Agregar"
2. Datos del empleado
3. Asignar rol y permisos
4. Turnos de trabajo
---
## Para Restaurantes
### Dashboard Restaurante
Accede a "Dashboard" → "Restaurant POS"
### Terminal POS
#### Crear Orden
1. Seleccionar mesa o llevar
2. Agregar items del menú
3. Aplicar descuentos (si aplica)
4. Enviar a cocina
#### Procesar Pago
1. Revisar total
2. Seleccionar método: Efectivo, Tarjeta, Digital
3. Procesar pago
4. Imprimir/enviar recibo
### Gestión de Menú
#### Agregar Platillo
1. "Menú" → "Agregar Item"
2. Información:
- Nombre
- Descripción
- Precio
- Categoría
- Foto
- Alérgenos
3. Guardar
#### Organizar Menú
- Crear categorías (Entradas, Platos Fuertes, Postres)
- Reordenar items
- Marcar items no disponibles temporalmente
### Gestión de Mesas
#### Configurar Mesas
1. "Mesas" → "Configurar"
2. Número de mesa
3. Capacidad
4. Ubicación (Interior/Terraza)
#### Estado de Mesas
- Disponible (verde)
- Ocupada (rojo)
- Reservada (amarillo)
- En limpieza (gris)
### Sistema de Cocina
#### Vista de Cocina
1. Órdenes llegan automáticamente
2. Estados:
- Pendiente
- En preparación
- Lista para servir
- Entregada
#### Gestionar Tiempos
- Ver tiempo desde que se ordenó
- Alertas de órdenes tardías
- Priorizar órdenes
### Reservaciones
#### Ver Reservaciones
- Calendario de reservas
- Lista del día
- Detalles de cada reserva
#### Gestionar Reservación
- Confirmar/Modificar
- Asignar mesa
- Notas especiales
---
## Para Comercios
### Dashboard Comercio
Accede a "Dashboard" → "Commerce"
### Mi Tienda
#### Configurar Tienda
1. "Mi Tienda" → "Configuración"
2. Información básica:
- Nombre del negocio
- Logo
- Descripción
- Horarios
- Ubicación
#### Personalizar
- Colores de marca
- Banner principal
- Secciones destacadas
### Gestión de Productos
#### Agregar Producto
1. "Inventario" → "Nuevo Producto"
2. Información:
- Nombre
- SKU/Código
- Descripción
- Precio
- Stock inicial
- Categoría
- Imágenes
3. Publicar
#### Gestionar Stock
- Ver niveles de inventario
- Alertas de stock bajo
- Actualizar cantidades
- Historial de movimientos
### Terminal POS
#### Proceso de Venta
1. Buscar productos por nombre o escanear código
2. Agregar al carrito
3. Ajustar cantidades
4. Aplicar descuentos
5. Procesar pago
6. Imprimir ticket
### Clientes
#### Base de Datos de Clientes
- Lista de clientes registrados
- Historial de compras
- Datos de contacto
#### Programa de Fidelidad
- Puntos acumulados
- Niveles de membresía
- Promociones exclusivas
### Reportes
#### Ver Reportes
1. "Reportes" → Seleccionar tipo
2. Opciones:
- Ventas por día/mes
- Productos más vendidos
- Análisis de clientes
- Inventario
3. Exportar a PDF/Excel
---
## Panel de Administración
### Acceso
Solo para usuarios con rol de Administrador o Super Admin.
### Dashboard Ejecutivo
#### Métricas Principales
- Total de usuarios
- Ingresos totales
- Reservas activas
- Servicios registrados
- Crecimiento mensual
#### Gráficos y Analytics
- Ingresos por mes
- Reservas por tipo
- Usuarios activos
- Destinos populares
### Gestión de Usuarios
#### Ver Usuarios
1. "Admin Panel" → "Usuarios"
2. Lista completa con filtros
3. Buscar por nombre, email, rol
#### Acciones
- Crear nuevo usuario
- Editar perfil
- Cambiar rol
- Suspender/Activar cuenta
- Eliminar usuario
### Roles y Permisos
#### Gestionar Roles
1. "Admin Panel" → "Usuarios" → "Roles & Permisos"
2. Ver roles por entidad:
- Admin
- Hotel
- Restaurant
- Commerce
#### Crear Rol Personalizado
1. "Crear Rol"
2. Nombre y descripción
3. Seleccionar permisos específicos
4. Asignar a usuarios
### Gestión de Contenido
#### Destinos
- Agregar/Editar destinos
- Información turística
- Puntos de interés
- Guías
#### Lugares
- Restaurantes
- Atracciones
- Shopping
- Servicios
### CRM
#### Gestión de Contactos
- Base de datos de clientes
- Segmentación
- Tags y categorías
- Historial de interacciones
#### Campañas de Marketing
1. "CRM" → "Campañas"
2. Crear nueva campaña
3. Seleccionar segmento
4. Diseñar mensaje
5. Programar envío
6. Ver resultados
### Channel Manager
#### Canales Conectados
- Booking.com
- Airbnb
- Expedia
- Otros OTAs
#### Sincronización
- Ver estado de sincronización
- Sincronizar manualmente
- Resolver conflictos
- Ver reservas por canal
### Sistema de Comisiones
#### Configurar Reglas
1. "Comisiones" → "Reglas"
2. Crear nueva regla
3. Definir:
- Tipo (porcentaje o fijo)
- Entidad (hotel, restaurant)
- Condiciones
4. Activar
#### Ver Pagos
- Historial de comisiones
- Comisiones pendientes
- Procesar pagos
- Exportar reportes
### Configuración Global
#### APIs Externas
- Google Maps API
- Stripe/PayPal
- Servicios de email
- Analytics
#### Integraciones
- Ver integraciones activas
- Configurar webhooks
- Logs de integración
#### Parámetros del Sistema
- Monedas disponibles
- Idiomas soportados
- Configuraciones generales
### Security Center
#### Monitoreo
- Intentos de login fallidos
- Actividad sospechosa
- Accesos no autorizados
#### Auditoría
- Logs de sistema
- Cambios en configuración
- Acciones de usuarios
---
## Preguntas Frecuentes
### General
**¿Karibeo es gratis?**
Karibeo ofrece planes gratuitos y de pago según el tipo de usuario. Los turistas pueden usar la plataforma sin costo. Los negocios tienen planes según sus necesidades.
**¿En qué países está disponible?**
Actualmente, Karibeo opera principalmente en el Caribe y América Latina, con expansión continua.
**¿Cómo contacto soporte?**
- Email: soporte@karibeo.com
- Chat en vivo: Disponible 24/7
- Teléfono: +1-809-555-KARIB
### Pagos
**¿Qué métodos de pago aceptan?**
- Tarjetas de crédito/débito (Visa, Mastercard, AmEx)
- PayPal
- Transferencias bancarias
- Billetera digital Karibeo
**¿Los pagos son seguros?**
Sí, usamos encriptación SSL y cumplimos con estándares PCI DSS para proteger tu información.
**¿Cuándo se procesa el reembolso?**
Según la política del establecimiento, generalmente de 5-10 días hábiles.
### Reservas
**¿Puedo modificar mi reserva?**
Sí, según disponibilidad y política del establecimiento. Ve a "Mis Reservas" y selecciona "Modificar".
**¿Qué pasa si cancelo?**
Las políticas de cancelación varían. Revisa los términos antes de reservar. Algunas reservas ofrecen cancelación gratuita.
**¿Cómo sé si mi reserva fue confirmada?**
Recibirás una confirmación por email y notificación en la app. También puedes verificar en "Mis Reservas".
### Técnico
**¿Hay app móvil?**
Actualmente, Karibeo es una aplicación web responsive que funciona en todos los dispositivos. App móvil nativa próximamente.
**¿Funciona sin internet?**
Algunas funciones básicas están disponibles offline, pero se requiere conexión para reservas y pagos.
**Mi cuenta está bloqueada, ¿qué hago?**
Contacta a soporte@karibeo.com con tu información para desbloquearla.
---
## Recursos Adicionales
- **Video Tutoriales**: [youtube.com/karibeo](https://youtube.com/karibeo)
- **Blog**: [blog.karibeo.com](https://blog.karibeo.com)
- **Centro de Ayuda**: [help.karibeo.com](https://help.karibeo.com)
- **Comunidad**: [community.karibeo.com](https://community.karibeo.com)
---
**¿Necesitas más ayuda?**
Contacta a nuestro equipo de soporte en cualquier momento.
**Última actualización**: 2025-01-12

View File

@@ -29,6 +29,10 @@ import Messages from "./pages/dashboard/Messages";
import Reviews from "./pages/dashboard/Reviews";
import Bookings from "./pages/dashboard/Bookings";
import Bookmarks from "./pages/dashboard/Bookmarks";
import Favorites from "./pages/dashboard/Favorites";
import Collections from "./pages/dashboard/Collections";
import TripsPage from "./pages/dashboard/Trips";
import QuizPage from "./pages/dashboard/Quiz";
import Profile from "./pages/dashboard/Profile";
import Settings from "./pages/dashboard/Settings";
import Invoices from "./pages/dashboard/Invoices";
@@ -65,6 +69,10 @@ import CRMDashboard from "./pages/dashboard/crm/CRMDashboard";
import CRMContacts from "./pages/dashboard/crm/Contacts";
import CRMCampaigns from "./pages/dashboard/crm/Campaigns";
import CRMAnalytics from "./pages/dashboard/crm/Analytics";
// Influencer pages
import InfluencerDashboard from "./pages/dashboard/influencer/InfluencerDashboard";
// Roles & Permissions
import RolesPermissions from "./pages/dashboard/RolesPermissions";
// Tourist App
import TouristApp from "./pages/TouristApp";
// Commerce pages (for retail stores)
@@ -124,10 +132,8 @@ const DashboardGate = () => {
}
const role = (user as any)?.role;
console.log('🚪 DashboardGate - checking role:', role, 'isAdmin?', (role === 'admin' || role === 'super_admin'));
if (role === 'admin' || role === 'super_admin') {
console.log('🚪 Redirecting to admin dashboard');
return <Navigate to="/dashboard/admin" replace />;
}
@@ -270,6 +276,39 @@ const AppRouter = () => (
</ProtectedRoute>
} />
{/* Phase 1 Routes - Favorites, Collections, Trips, Quiz */}
<Route path="/dashboard/favorites" element={
<ProtectedRoute>
<DashboardLayout>
<Favorites />
</DashboardLayout>
</ProtectedRoute>
} />
<Route path="/dashboard/collections" element={
<ProtectedRoute>
<DashboardLayout>
<Collections />
</DashboardLayout>
</ProtectedRoute>
} />
<Route path="/dashboard/trips" element={
<ProtectedRoute>
<DashboardLayout>
<TripsPage />
</DashboardLayout>
</ProtectedRoute>
} />
<Route path="/dashboard/quiz" element={
<ProtectedRoute>
<DashboardLayout>
<QuizPage />
</DashboardLayout>
</ProtectedRoute>
} />
<Route path="/dashboard/profile" element={
<ProtectedRoute>
<DashboardLayout>
@@ -350,6 +389,14 @@ const AppRouter = () => (
</ProtectedRoute>
} />
<Route path="/dashboard/roles-permissions" element={
<ProtectedRoute>
<DashboardLayout>
<RolesPermissions />
</DashboardLayout>
</ProtectedRoute>
} />
{/* Commerce Routes */}
<Route path="/dashboard/commerce/store" element={
<ProtectedRoute>
@@ -693,6 +740,47 @@ const AppRouter = () => (
</ProtectedRoute>
} />
{/* Influencer Dashboard Routes */}
<Route path="/dashboard/influencer" element={
<ProtectedRoute>
<DashboardLayout>
<InfluencerDashboard />
</DashboardLayout>
</ProtectedRoute>
} />
<Route path="/dashboard/influencer/stats" element={
<ProtectedRoute>
<DashboardLayout>
<InfluencerDashboard />
</DashboardLayout>
</ProtectedRoute>
} />
<Route path="/dashboard/influencer/campaigns" element={
<ProtectedRoute>
<DashboardLayout>
<InfluencerDashboard />
</DashboardLayout>
</ProtectedRoute>
} />
<Route path="/dashboard/influencer/earnings" element={
<ProtectedRoute>
<DashboardLayout>
<InfluencerDashboard />
</DashboardLayout>
</ProtectedRoute>
} />
<Route path="/dashboard/influencer/profile" element={
<ProtectedRoute>
<DashboardLayout>
<InfluencerDashboard />
</DashboardLayout>
</ProtectedRoute>
} />
{/* Catch-all route */}
<Route path="*" element={<NotFound />} />
</Routes>

View File

@@ -60,7 +60,9 @@ import {
Server,
ShieldAlert,
UserCircle,
Mail
Mail,
TrendingUp,
Instagram
} from 'lucide-react';
const DashboardLayout = ({ children }: { children: React.ReactNode }) => {
@@ -87,7 +89,15 @@ const DashboardLayout = ({ children }: { children: React.ReactNode }) => {
path: '/dashboard/admin',
subItems: [
{ icon: BarChart3, label: 'Resumen General', path: '/dashboard/admin?tab=overview' },
{ icon: Users, label: 'Usuarios', path: '/dashboard/admin?tab=users' },
{
icon: Users,
label: 'Usuarios',
path: '/dashboard/admin?tab=users',
subItems: [
{ icon: Users, label: 'Lista de Usuarios', path: '/dashboard/admin?tab=users' },
{ icon: Shield, label: 'Roles & Permisos', path: '/dashboard/roles-permissions' }
]
},
{ icon: MapPin, label: 'Proveedores', path: '/dashboard/admin?tab=services' },
{ icon: DollarSign, label: 'Financiero', path: '/dashboard/admin?tab=financial' },
{
@@ -219,6 +229,18 @@ const DashboardLayout = ({ children }: { children: React.ReactNode }) => {
{ icon: BookOpen, label: 'Biblioteca', path: '/dashboard/guides/library' }
]
},
{
icon: Instagram,
label: 'Influencer Dashboard',
path: '/dashboard/influencer',
subItems: [
{ icon: Home, label: 'Mi Dashboard', path: '/dashboard/influencer' },
{ icon: BarChart3, label: 'Mis Estadísticas', path: '/dashboard/influencer/stats' },
{ icon: Megaphone, label: 'Campañas', path: '/dashboard/influencer/campaigns' },
{ icon: TrendingUp, label: 'Earnings', path: '/dashboard/influencer/earnings' },
{ icon: Users, label: 'Mi Perfil Público', path: '/dashboard/influencer/profile' }
]
},
{
icon: Store,
label: t('commerce'),
@@ -260,8 +282,8 @@ const DashboardLayout = ({ children }: { children: React.ReactNode }) => {
<div className="decoration blur-2"></div>
<div className="decoration blur-3"></div>
{/* Sidebar */}
<nav className={`fixed top-0 left-0 z-50 h-screen bg-white border-r-4 border-white transition-all duration-300 ${sidebarCollapsed ? 'w-16' : 'w-80'}`} style={{ minWidth: sidebarCollapsed ? '64px' : '320px', maxWidth: sidebarCollapsed ? '64px' : '320px', backdropFilter: 'blur(15px)', backgroundColor: 'rgba(255, 255, 255, 0.9)' }}>
{/* Sidebar - Hidden on mobile, collapsible on desktop */}
<nav className={`fixed top-0 left-0 z-50 h-screen bg-white border-r-4 border-white transition-all duration-300 ${sidebarCollapsed ? 'w-16' : 'w-80'} md:block hidden`} style={{ minWidth: sidebarCollapsed ? '64px' : '320px', maxWidth: sidebarCollapsed ? '64px' : '320px', backdropFilter: 'blur(15px)', backgroundColor: 'rgba(255, 255, 255, 0.9)' }}>
{/* Sidebar Header */}
<div className="flex items-center justify-between p-6 h-20 border-b border-gray-100">
<Link to="/dashboard" className={`flex items-center space-x-2 ${sidebarCollapsed ? 'justify-center' : ''}`}>
@@ -500,87 +522,91 @@ const DashboardLayout = ({ children }: { children: React.ReactNode }) => {
</div>
</nav>
{/* Main Content Area */}
<div className={`transition-all duration-300 ${sidebarCollapsed ? 'ml-16' : 'ml-80'}`}>
{/* Top Navigation */}
<nav className="h-20 px-6 py-4 z-10 relative" style={{ backgroundColor: 'rgba(255, 255, 255, 0.7)', backdropFilter: 'blur(15px)' }}>
{/* Main Content Area - Responsive */}
<div className={`transition-all duration-300 ${sidebarCollapsed ? 'md:ml-16 ml-0' : 'md:ml-80 ml-0'}`}>
{/* Top Navigation - Responsive */}
<nav className="h-16 md:h-20 px-4 md:px-6 py-3 md:py-4 z-10 relative" style={{ backgroundColor: 'rgba(255, 255, 255, 0.7)', backdropFilter: 'blur(15px)' }}>
<div className="flex items-center justify-between">
{/* Left Side */}
<div className="flex items-center space-x-4">
{/* Left Side - Responsive */}
<div className="flex items-center space-x-2 md:space-x-4">
<button
onClick={() => setSidebarCollapsed(!sidebarCollapsed)}
className="p-2 rounded-full text-white transition-colors"
className="p-1.5 md:p-2 rounded-full text-white transition-colors"
style={{ backgroundColor: '#F84525' }}
>
<Menu className="w-5 h-5" />
<Menu className="w-4 h-4 md:w-5 md:h-5" />
</button>
{/* Search */}
<div className="relative">
{/* Search - Hidden on mobile */}
<div className="relative hidden md:block">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<Search className="h-4 w-4" style={{ color: '#69534f' }} />
</div>
<input
type="text"
placeholder={`${t('search')} (Ctrl+/)`}
className="block w-80 pl-12 pr-16 py-3 border border-white rounded-xl text-sm placeholder-gray-500 focus:outline-none focus:ring-1 focus:border-red-500"
className="block w-48 lg:w-80 pl-12 pr-16 py-3 border border-white rounded-xl text-sm placeholder-gray-500 focus:outline-none focus:ring-1 focus:border-red-500"
style={{
backgroundColor: '#fff',
borderColor: '#fff',
height: '48px',
height: '44px',
borderRadius: '0.8rem'
}}
/>
<span className="absolute inset-y-0 right-0 pr-3 flex items-center text-xs font-bold px-2 py-1 rounded" style={{ backgroundColor: '#f8f4f3', borderColor: '#f8f4f3', top: '50%', transform: 'translateY(-50%)', right: '8px', fontSize: '12px' }}>
<span className="absolute inset-y-0 right-0 pr-3 flex items-center text-xs font-bold px-2 py-1 rounded hidden lg:flex" style={{ backgroundColor: '#f8f4f3', borderColor: '#f8f4f3', top: '50%', transform: 'translateY(-50%)', right: '8px', fontSize: '12px' }}>
(Ctrl+/)
</span>
</div>
</div>
{/* Right Side */}
<div className="flex items-center space-x-4">
{/* Language Selector */}
{/* Right Side - Responsive */}
<div className="flex items-center space-x-2 md:space-x-4">
{/* Language Selector - Hidden on small mobile */}
<div className="hidden sm:block">
<LanguageSelector />
</div>
{/* Currency Selector */}
{/* Currency Selector - Hidden on small mobile */}
<div className="hidden sm:block">
<CurrencySelector />
</div>
{/* Refresh (Admin) */}
{/* Refresh (Admin) - Hidden on mobile */}
<button
onClick={() => window.dispatchEvent(new CustomEvent('admin:refresh'))}
className="p-2 rounded-xl transition-colors"
className="hidden md:block p-2 rounded-xl transition-colors"
style={{ backgroundColor: '#21272f', borderColor: '#21272f', color: '#F84525' }}
title="Actualizar datos"
>
<RefreshCw className="w-5 h-5" />
<RefreshCw className="w-4 h-4 md:w-5 md:h-5" />
</button>
{/* Notifications */}
<button className="p-2 rounded-xl transition-colors relative" style={{ backgroundColor: '#21272f', borderColor: '#21272f', color: '#F84525' }} title="Notificaciones">
<Bell className="w-5 h-5" />
<button className="p-1.5 md:p-2 rounded-xl transition-colors relative" style={{ backgroundColor: '#21272f', borderColor: '#21272f', color: '#F84525' }} title="Notificaciones">
<Bell className="w-4 h-4 md:w-5 md:h-5" />
<span className="absolute -top-1 -right-1 bg-red-500 text-white text-[10px] px-1 py-0.5 rounded-full">!</span>
</button>
{/* Theme Toggle */}
<button className="p-2 rounded-xl transition-colors" style={{ backgroundColor: '#21272f', borderColor: '#21272f', color: '#F84525' }}>
{/* Theme Toggle - Hidden on mobile */}
<button className="hidden md:block p-2 rounded-xl transition-colors" style={{ backgroundColor: '#21272f', borderColor: '#21272f', color: '#F84525' }}>
<Sun className="w-5 h-5" />
</button>
{/* Fullscreen */}
<button className="p-2 rounded-xl transition-colors" style={{ backgroundColor: '#21272f', borderColor: '#21272f', color: '#F84525' }}>
{/* Fullscreen - Hidden on mobile */}
<button className="hidden lg:block p-2 rounded-xl transition-colors" style={{ backgroundColor: '#21272f', borderColor: '#21272f', color: '#F84525' }}>
<Maximize className="w-5 h-5" />
</button>
{/* User Profile */}
<div className="flex items-center space-x-3 pl-4">
<div className="flex items-center space-x-3">
<div className="w-10 h-10 bg-gradient-to-br from-orange-400 to-red-500 rounded-full flex items-center justify-center relative">
<span className="text-white font-semibold text-sm">
{/* User Profile - Responsive */}
<div className="flex items-center space-x-2 md:space-x-3 pl-2 md:pl-4">
<div className="flex items-center space-x-2 md:space-x-3">
<div className="w-8 h-8 md:w-10 md:h-10 bg-gradient-to-br from-orange-400 to-red-500 rounded-full flex items-center justify-center relative">
<span className="text-white font-semibold text-xs md:text-sm">
{user?.name?.[0] || user?.email?.[0] || 'U'}
</span>
<div className="absolute -bottom-1 -right-1 w-4 h-4 bg-green-500 border-2 border-white rounded-full"></div>
<div className="absolute -bottom-1 -right-1 w-3 h-3 md:w-4 md:h-4 bg-green-500 border-2 border-white rounded-full"></div>
</div>
<div className="text-left">
<div className="text-left hidden md:block">
<div className="font-semibold text-gray-800 text-sm flex items-center space-x-2">
<span>{user?.name || 'Usuario'}</span>
{user?.role === 'super_admin' && (
@@ -597,8 +623,8 @@ const DashboardLayout = ({ children }: { children: React.ReactNode }) => {
</div>
</nav>
{/* Page Content */}
<main className="p-6">
{/* Page Content - Responsive padding */}
<main className="p-3 md:p-6">
{children}
</main>
</div>

View File

@@ -41,11 +41,11 @@ const ExploreSection = () => {
</p>
</div>
{/* Carousel Container */}
{/* Carousel Container - Fully responsive grid */}
<div className="owl-carousel-container relative">
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-6 overflow-x-auto pb-4">
{/* Region Card 1 */}
<div className="region-card rounded-xl overflow-hidden relative text-white min-w-[300px] h-[458px] group cursor-pointer">
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 md:gap-6 overflow-x-auto pb-4">
{/* Region Card 1 - Responsive height */}
<div className="region-card rounded-xl overflow-hidden relative text-white min-w-[280px] sm:min-w-[300px] h-[350px] sm:h-[400px] lg:h-[458px] group cursor-pointer">
<div className="region-card-image h-full">
<img
src="https://images.visitarepublicadominicana.org/Punta-Cana-Republica-Dominicana.jpg"

View File

@@ -0,0 +1,409 @@
import { useState, useEffect } from "react";
import {
Users,
Star,
Instagram,
Youtube,
Twitter,
MapPin,
TrendingUp,
Heart,
MessageCircle,
Filter,
Search,
Loader2,
BadgeCheck,
Globe,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
interface Influencer {
id: string;
userId: string;
username: string;
displayName: string;
avatar?: string;
bio?: string;
followers: number;
engagement: number;
categories: string[];
platforms: string[];
location?: string;
verified: boolean;
rating: number;
campaignsCompleted: number;
pricePerPost?: number;
}
const categoryFilters = [
{ id: "all", label: "Todos" },
{ id: "travel", label: "Viajes" },
{ id: "food", label: "Gastronomía" },
{ id: "lifestyle", label: "Lifestyle" },
{ id: "adventure", label: "Aventura" },
{ id: "luxury", label: "Lujo" },
];
const InfluencerMarketplace = () => {
const [influencers, setInfluencers] = useState<Influencer[]>([]);
const [loading, setLoading] = useState(true);
const [searchQuery, setSearchQuery] = useState("");
const [selectedCategory, setSelectedCategory] = useState("all");
const [favorites, setFavorites] = useState<string[]>([]);
useEffect(() => {
fetchInfluencers();
}, [selectedCategory]);
const fetchInfluencers = async () => {
setLoading(true);
try {
// TODO: Conectar con API real
// const params: Record<string, unknown> = { page: 1, limit: 20 };
// if (selectedCategory !== "all") {
// params.category = selectedCategory;
// }
// const result = await api.getInfluencerMarketplace(params);
// setInfluencers(result.influencers || result || []);
// Mock data para demo
await new Promise(resolve => setTimeout(resolve, 500));
setInfluencers([
{
id: "1",
userId: "u1",
username: "maria_viajera",
displayName: "Maria Rodriguez",
avatar: "https://images.unsplash.com/photo-1494790108377-be9c29b29330?w=150",
bio: "Exploradora del Caribe. Comparto los mejores destinos y experiencias de viaje.",
followers: 125000,
engagement: 4.8,
categories: ["travel", "lifestyle"],
platforms: ["instagram", "youtube"],
location: "Santo Domingo, RD",
verified: true,
rating: 4.9,
campaignsCompleted: 45,
pricePerPost: 500,
},
{
id: "2",
userId: "u2",
username: "chef_carlos",
displayName: "Carlos Mendez",
avatar: "https://images.unsplash.com/photo-1507003211169-0a1dd7228f2d?w=150",
bio: "Chef y food blogger. Descubriendo la gastronomía dominicana.",
followers: 89000,
engagement: 5.2,
categories: ["food", "travel"],
platforms: ["instagram", "twitter"],
location: "Santiago, RD",
verified: true,
rating: 4.7,
campaignsCompleted: 32,
pricePerPost: 350,
},
{
id: "3",
userId: "u3",
username: "adventure_rd",
displayName: "Pedro Adventures",
avatar: "https://images.unsplash.com/photo-1500648767791-00dcc994a43e?w=150",
bio: "Aventuras extremas en el Caribe. Rafting, parapente, buceo y más.",
followers: 67000,
engagement: 6.1,
categories: ["adventure", "travel"],
platforms: ["youtube", "instagram"],
location: "Jarabacoa, RD",
verified: false,
rating: 4.8,
campaignsCompleted: 18,
pricePerPost: 280,
},
{
id: "4",
userId: "u4",
username: "luxury_caribbean",
displayName: "Ana Luxury Travel",
avatar: "https://images.unsplash.com/photo-1438761681033-6461ffad8d80?w=150",
bio: "Experiencias de lujo en el Caribe. Resorts, spas y gastronomía premium.",
followers: 210000,
engagement: 3.9,
categories: ["luxury", "lifestyle", "travel"],
platforms: ["instagram"],
location: "Punta Cana, RD",
verified: true,
rating: 4.6,
campaignsCompleted: 78,
pricePerPost: 1200,
},
{
id: "5",
userId: "u5",
username: "rd_foodie",
displayName: "Laura Foodie",
avatar: "https://images.unsplash.com/photo-1544005313-94ddf0286df2?w=150",
bio: "Descubriendo la mejor comida callejera y restaurantes de RD.",
followers: 45000,
engagement: 7.2,
categories: ["food", "lifestyle"],
platforms: ["instagram", "twitter"],
location: "Santo Domingo, RD",
verified: false,
rating: 4.5,
campaignsCompleted: 12,
pricePerPost: 200,
},
{
id: "6",
userId: "u6",
username: "caribbean_surfer",
displayName: "Diego Surf",
avatar: "https://images.unsplash.com/photo-1506794778202-cad84cf45f1d?w=150",
bio: "Surf, kitesurf y deportes acuáticos en las mejores playas del Caribe.",
followers: 156000,
engagement: 5.5,
categories: ["adventure", "lifestyle"],
platforms: ["youtube", "instagram"],
location: "Cabarete, RD",
verified: true,
rating: 4.8,
campaignsCompleted: 56,
pricePerPost: 650,
},
]);
} catch (error) {
console.error("Error fetching influencers:", error);
} finally {
setLoading(false);
}
};
const toggleFavorite = (id: string) => {
setFavorites((prev) =>
prev.includes(id) ? prev.filter((f) => f !== id) : [...prev, id]
);
};
const formatFollowers = (count: number) => {
if (count >= 1000000) return `${(count / 1000000).toFixed(1)}M`;
if (count >= 1000) return `${(count / 1000).toFixed(0)}K`;
return count.toString();
};
const getPlatformIcon = (platform: string) => {
switch (platform.toLowerCase()) {
case "instagram":
return Instagram;
case "youtube":
return Youtube;
case "twitter":
return Twitter;
default:
return Globe;
}
};
const filteredInfluencers = influencers.filter((inf) => {
const matchesSearch =
inf.displayName.toLowerCase().includes(searchQuery.toLowerCase()) ||
inf.username.toLowerCase().includes(searchQuery.toLowerCase()) ||
inf.bio?.toLowerCase().includes(searchQuery.toLowerCase());
const matchesCategory =
selectedCategory === "all" ||
inf.categories.includes(selectedCategory);
return matchesSearch && matchesCategory;
});
return (
<div className="space-y-6">
{/* Header */}
<div className="flex flex-col sm:flex-row gap-4 items-start sm:items-center justify-between">
<div>
<h2 className="text-xl font-bold flex items-center gap-2 text-gray-900">
<Users className="w-5 h-5 text-orange-500" />
Marketplace de Influencers
</h2>
<p className="text-sm text-gray-600">
Conecta con creadores de contenido para promocionar tu destino
</p>
</div>
<Button className="bg-gradient-to-r from-orange-500 to-red-500 hover:from-orange-600 hover:to-red-600">
<TrendingUp className="w-4 h-4 mr-2" />
Crear Campaña
</Button>
</div>
{/* Search & Filters */}
<div className="flex flex-col sm:flex-row gap-3">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-400" />
<Input
placeholder="Buscar influencers..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-10"
/>
</div>
<Button variant="outline" size="icon">
<Filter className="h-4 w-4" />
</Button>
</div>
{/* Category Filters */}
<div className="flex gap-2 overflow-x-auto pb-2">
{categoryFilters.map((cat) => (
<button
key={cat.id}
onClick={() => setSelectedCategory(cat.id)}
className={`whitespace-nowrap rounded-full px-4 py-2 text-sm font-medium transition-colors ${
selectedCategory === cat.id
? "bg-orange-500 text-white"
: "bg-gray-100 text-gray-700 hover:bg-gray-200"
}`}
>
{cat.label}
</button>
))}
</div>
{/* Loading */}
{loading && (
<div className="flex justify-center py-12">
<Loader2 className="w-8 h-8 animate-spin text-orange-500" />
</div>
)}
{/* Influencer Grid */}
{!loading && (
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
{filteredInfluencers.map((influencer) => (
<div
key={influencer.id}
className="bg-white border rounded-xl p-4 hover:shadow-lg transition-shadow"
>
{/* Header */}
<div className="flex items-start gap-3 mb-3">
<div className="relative">
<img
src={influencer.avatar || "https://via.placeholder.com/60"}
alt={influencer.displayName}
className="w-14 h-14 rounded-full object-cover"
/>
{influencer.verified && (
<BadgeCheck className="absolute -bottom-1 -right-1 w-5 h-5 text-blue-500 bg-white rounded-full" />
)}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1">
<h3 className="font-semibold truncate text-gray-900">{influencer.displayName}</h3>
</div>
<p className="text-sm text-gray-500">@{influencer.username}</p>
{influencer.location && (
<div className="flex items-center gap-1 text-xs text-gray-500 mt-1">
<MapPin className="w-3 h-3" />
{influencer.location}
</div>
)}
</div>
<button
onClick={() => toggleFavorite(influencer.id)}
className="p-2 rounded-full hover:bg-gray-100 transition-colors"
>
<Heart
className={`w-5 h-5 ${
favorites.includes(influencer.id)
? "fill-red-500 text-red-500"
: "text-gray-400"
}`}
/>
</button>
</div>
{/* Bio */}
{influencer.bio && (
<p className="text-sm text-gray-600 line-clamp-2 mb-3">
{influencer.bio}
</p>
)}
{/* Stats */}
<div className="grid grid-cols-3 gap-2 mb-3">
<div className="text-center p-2 bg-gray-50 rounded-lg">
<p className="text-lg font-bold text-gray-900">{formatFollowers(influencer.followers)}</p>
<p className="text-xs text-gray-500">Seguidores</p>
</div>
<div className="text-center p-2 bg-gray-50 rounded-lg">
<p className="text-lg font-bold text-gray-900">{influencer.engagement}%</p>
<p className="text-xs text-gray-500">Engagement</p>
</div>
<div className="text-center p-2 bg-gray-50 rounded-lg">
<div className="flex items-center justify-center gap-1">
<Star className="w-4 h-4 fill-yellow-400 text-yellow-400" />
<span className="text-lg font-bold text-gray-900">{influencer.rating}</span>
</div>
<p className="text-xs text-gray-500">Rating</p>
</div>
</div>
{/* Platforms */}
<div className="flex items-center gap-2 mb-3">
{influencer.platforms.map((platform) => {
const Icon = getPlatformIcon(platform);
return (
<div
key={platform}
className="p-1.5 bg-gray-100 rounded-lg"
title={platform}
>
<Icon className="w-4 h-4 text-gray-600" />
</div>
);
})}
<div className="flex-1" />
{influencer.categories.slice(0, 2).map((cat) => (
<Badge key={cat} variant="outline" className="text-xs capitalize">
{cat}
</Badge>
))}
</div>
{/* Footer */}
<div className="flex items-center justify-between pt-3 border-t">
<div>
{influencer.pricePerPost && (
<p className="text-lg font-bold text-orange-500">
${influencer.pricePerPost}
<span className="text-xs font-normal text-gray-500">/post</span>
</p>
)}
<p className="text-xs text-gray-500">
{influencer.campaignsCompleted} campañas
</p>
</div>
<Button size="sm" className="bg-gradient-to-r from-orange-500 to-red-500 hover:from-orange-600 hover:to-red-600">
<MessageCircle className="w-4 h-4 mr-1" />
Contactar
</Button>
</div>
</div>
))}
</div>
)}
{/* Empty State */}
{!loading && filteredInfluencers.length === 0 && (
<div className="text-center py-12">
<Users className="w-12 h-12 mx-auto mb-4 text-gray-400" />
<p className="text-gray-500">No se encontraron influencers</p>
</div>
)}
</div>
);
};
export default InfluencerMarketplace;

View File

@@ -58,7 +58,7 @@ const PlacesSection = () => {
return (
<section className="py-20 bg-gray-50 relative overflow-hidden">
<div className="max-w-7xl mx-auto px-4 py-4">
<div className="grid lg:grid-cols-12 gap-8">
<div className="grid grid-cols-1 lg:grid-cols-12 gap-6 md:gap-8">
{/* Sidebar */}
<div className="lg:col-span-4 sidebar">
<div className="text-center lg:text-left mb-12">
@@ -97,7 +97,7 @@ const PlacesSection = () => {
<div className="space-y-6">
{places.map((place, index) => (
<a href={`/offer/${index + 1}`} key={index} className="bg-white rounded-xl overflow-hidden border-0 shadow-soft hover:shadow-medium transition-all duration-300 group cursor-pointer block">
<div className="grid md:grid-cols-12 gap-0">
<div className="grid grid-cols-1 md:grid-cols-12 gap-0">
{/* Image */}
<div className="md:col-span-5 relative bg-white">
<div className="overflow-hidden relative h-64 md:h-full">
@@ -118,8 +118,8 @@ const PlacesSection = () => {
</div>
</div>
{/* Content */}
<div className="md:col-span-7 p-6 flex flex-col">
{/* Content - Responsive padding */}
<div className="md:col-span-7 p-4 md:p-6 flex flex-col">
{/* Action buttons */}
<div className="flex gap-2 justify-end mb-4">
<button className="w-10 h-10 bg-white shadow-sm rounded-full flex items-center justify-center text-primary hover:bg-primary hover:text-white transition-colors">

View File

@@ -0,0 +1,228 @@
import React, { useState } from 'react';
import { Card } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Input } from '@/components/ui/input';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog';
import { Checkbox } from '@/components/ui/checkbox';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { Plus, Edit, Trash2, Users, Shield } from 'lucide-react';
import { EntityType, Role, PERMISSIONS } from '@/types/roles';
import { useRolesPermissions } from '@/hooks/useRolesPermissions';
interface RoleManagementProps {
entityType: EntityType;
}
const RoleManagement: React.FC<RoleManagementProps> = ({ entityType }) => {
const { roles, getRolesByEntity, createRole, updateRole, deleteRole } = useRolesPermissions();
const [isCreateOpen, setIsCreateOpen] = useState(false);
const [editingRole, setEditingRole] = useState<Role | null>(null);
const [formData, setFormData] = useState({
name: '',
description: '',
permissions: [] as string[],
});
const entityRoles = getRolesByEntity(entityType);
const availablePermissions = PERMISSIONS[entityType] || [];
const getEntityTitle = () => {
const titles: Record<EntityType, string> = {
admin: 'Admin System',
hotel: 'Hotel Management',
restaurant: 'Restaurant Management',
commerce: 'Commerce Management',
};
return titles[entityType];
};
const handleSubmit = () => {
if (editingRole) {
updateRole(editingRole.id, formData);
setEditingRole(null);
} else {
createRole({
...formData,
entityType,
});
}
setIsCreateOpen(false);
setFormData({ name: '', description: '', permissions: [] });
};
const handleEdit = (role: Role) => {
setEditingRole(role);
setFormData({
name: role.name,
description: role.description,
permissions: role.permissions,
});
setIsCreateOpen(true);
};
const handleDelete = (roleId: string) => {
if (confirm('Are you sure you want to delete this role?')) {
deleteRole(roleId);
}
};
const togglePermission = (permissionId: string) => {
setFormData(prev => ({
...prev,
permissions: prev.permissions.includes(permissionId)
? prev.permissions.filter(p => p !== permissionId)
: [...prev.permissions, permissionId],
}));
};
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<Shield className="w-6 h-6 text-primary" />
<div>
<h2 className="text-2xl font-bold">{getEntityTitle()}</h2>
<p className="text-muted-foreground">Manage roles and permissions</p>
</div>
</div>
<Dialog open={isCreateOpen} onOpenChange={setIsCreateOpen}>
<DialogTrigger asChild>
<Button onClick={() => {
setEditingRole(null);
setFormData({ name: '', description: '', permissions: [] });
}}>
<Plus className="w-4 h-4 mr-2" />
New Role
</Button>
</DialogTrigger>
<DialogContent className="max-w-2xl max-h-[80vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>{editingRole ? 'Edit Role' : 'Create New Role'}</DialogTitle>
<DialogDescription>
Define role name, description, and permissions
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div>
<Label>Role Name</Label>
<Input
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
placeholder="e.g., Manager, Operator"
/>
</div>
<div>
<Label>Description</Label>
<Textarea
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
placeholder="Brief description of this role"
/>
</div>
<div>
<Label className="mb-3 block">Permissions</Label>
<div className="space-y-4">
{Object.entries(
availablePermissions.reduce((acc, perm) => {
if (!acc[perm.module]) acc[perm.module] = [];
acc[perm.module].push(perm);
return acc;
}, {} as Record<string, typeof availablePermissions>)
).map(([module, perms]) => (
<div key={module} className="border rounded-lg p-4">
<h4 className="font-semibold mb-3">{module}</h4>
<div className="space-y-2">
{perms.map((perm) => (
<div key={perm.id} className="flex items-center space-x-2">
<Checkbox
id={perm.id}
checked={formData.permissions.includes(perm.id)}
onCheckedChange={() => togglePermission(perm.id)}
/>
<Label htmlFor={perm.id} className="flex-1 cursor-pointer">
<div className="font-medium">{perm.name}</div>
<div className="text-sm text-muted-foreground">{perm.description}</div>
</Label>
</div>
))}
</div>
</div>
))}
</div>
</div>
<div className="flex justify-end gap-2 pt-4">
<Button variant="outline" onClick={() => setIsCreateOpen(false)}>
Cancel
</Button>
<Button onClick={handleSubmit} disabled={!formData.name || formData.permissions.length === 0}>
{editingRole ? 'Update' : 'Create'} Role
</Button>
</div>
</div>
</DialogContent>
</Dialog>
</div>
<div className="grid gap-4">
{entityRoles.map((role) => (
<Card key={role.id} className="p-6">
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="flex items-center gap-3 mb-2">
<h3 className="text-lg font-semibold">{role.name}</h3>
{role.isSystem && (
<Badge variant="secondary">System Role</Badge>
)}
<div className="flex items-center gap-1 text-muted-foreground">
<Users className="w-4 h-4" />
<span className="text-sm">{role.userCount} users</span>
</div>
</div>
<p className="text-muted-foreground mb-4">{role.description}</p>
<div className="flex flex-wrap gap-2">
{role.permissions.map((permId) => {
const perm = availablePermissions.find(p => p.id === permId);
return perm ? (
<Badge key={permId} variant="outline">
{perm.name}
</Badge>
) : null;
})}
</div>
</div>
<div className="flex gap-2">
<Button
variant="ghost"
size="icon"
onClick={() => handleEdit(role)}
>
<Edit className="w-4 h-4" />
</Button>
{!role.isSystem && (
<Button
variant="ghost"
size="icon"
onClick={() => handleDelete(role.id)}
>
<Trash2 className="w-4 h-4" />
</Button>
)}
</div>
</div>
</Card>
))}
</div>
</div>
);
};
export default RoleManagement;

View File

@@ -1,6 +1,6 @@
// API Configuration and Constants
export const API_CONFIG = {
BASE_URL: 'https://karibeo.lesoluciones.net:8443/api/v1',
BASE_URL: 'https://api.karibeo.ai:8443/api/v1',
ENDPOINTS: {
// Authentication
LOGIN: '/auth/login',
@@ -43,6 +43,37 @@ export const API_CONFIG = {
HOTEL_ROOM_SERVICE: '/hotel/room-service',
HOTEL_STATS: '/hotel/establishments/:id/stats',
HOTEL_HOUSEKEEPING: '/hotel/establishments/:id/housekeeping',
// Favorites (Fase 1)
FAVORITES: '/favorites',
FAVORITES_MY: '/favorites/my',
FAVORITES_COUNTS: '/favorites/my/counts',
FAVORITES_CHECK: '/favorites/check/:itemType/:itemId',
FAVORITES_TOGGLE: '/favorites/toggle',
// Collections (Fase 1)
COLLECTIONS: '/collections',
COLLECTIONS_MY: '/collections/my',
COLLECTIONS_STATS: '/collections/my/stats',
COLLECTION_ITEMS: '/collections/:id/items',
COLLECTION_ITEMS_ORDER: '/collections/:id/items/order',
COLLECTIONS_ORDER: '/collections/order',
// Trips (Fase 1)
TRIPS: '/trips',
TRIPS_MY: '/trips/my',
TRIPS_STATS: '/trips/my/stats',
TRIP_DAYS: '/trips/:tripId/days',
TRIP_DAY: '/trips/:tripId/days/:dayId',
TRIP_ACTIVITIES: '/trips/:tripId/days/:dayId/activities',
TRIP_ACTIVITY: '/trips/:tripId/days/:dayId/activities/:activityId',
TRIP_ACTIVITIES_ORDER: '/trips/:tripId/days/:dayId/activities/order',
// Quiz (Fase 1)
QUIZ_QUESTIONS: '/quiz/questions',
QUIZ_MY: '/quiz/my',
QUIZ_SUBMIT: '/quiz/submit',
QUIZ_RESET: '/quiz/reset',
},
// External Assets

View File

@@ -72,78 +72,7 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children
const login = async (email: string, password: string) => {
setIsLoading(true);
try {
console.log('Login attempt with:', { email, password: '***' });
// Mock users for testing
const mockUsers = {
'superadmin@karibeo.com': {
id: '1',
email: 'superadmin@karibeo.com',
name: 'Super Admin',
role: 'super_admin' as const,
type: 'business' as const,
avatar: '/api/placeholder/40/40',
location: { lat: 18.4861, lng: -69.9312 },
preferences: { language: 'es' },
wallet: { balance: 0, currency: 'USD' },
profile: {
phone: '+1-809-555-0001',
address: 'Santo Domingo, República Dominicana',
joinedDate: '2023-01-01',
permissions: ['all']
}
},
'admin@karibeo.com': {
id: '2',
email: 'admin@karibeo.com',
name: 'Admin User',
role: 'admin' as const,
type: 'business' as const,
avatar: '/api/placeholder/40/40',
location: { lat: 18.4861, lng: -69.9312 },
preferences: { language: 'es' },
wallet: { balance: 0, currency: 'USD' },
profile: {
phone: '+1-809-555-0002',
address: 'Santiago, República Dominicana',
joinedDate: '2023-02-01',
permissions: ['user_management', 'content_management']
}
},
'user@karibeo.com': {
id: '3',
email: 'user@karibeo.com',
name: 'Regular User',
role: 'tourist' as const,
type: 'tourist' as const,
avatar: '/api/placeholder/40/40',
location: { lat: 18.4861, lng: -69.9312 },
preferences: { language: 'es' },
wallet: { balance: 150.50, currency: 'USD' },
profile: {
phone: '+1-809-555-0003',
address: 'Punta Cana, República Dominicana',
joinedDate: '2023-03-01',
permissions: ['booking', 'reviews']
}
}
};
// Check if it's a mock user
const mockUser = mockUsers[email as keyof typeof mockUsers];
if (mockUser && password === '123456') {
console.log('🎯 Mock login successful for:', email, 'with role:', mockUser.role);
const token = `mock-token-${Date.now()}`;
localStorage.setItem('karibeo-token', token);
localStorage.setItem('karibeo_token', token);
localStorage.setItem('karibeo-user', JSON.stringify(mockUser));
setUser(mockUser);
setIsLoading(false);
return;
}
const loginData = { email: email.trim(), password: password.trim() };
console.log('Sending login data (form first):', { email: loginData.email, password: '***' });
let loginRes: any;
// Try application/x-www-form-urlencoded first (some backends validate this path)

View File

@@ -14,7 +14,6 @@ export const useAdminData = () => {
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
// Check if user has admin permissions
const isAdmin = user?.role === 'admin' || user?.role === 'super_admin';
const isSuperAdmin = user?.role === 'super_admin';

224
src/hooks/useCollections.ts Normal file
View File

@@ -0,0 +1,224 @@
import { useState, useEffect, useCallback } from 'react';
import { collectionsApi, Collection, CollectionStats, CreateCollectionDto, UpdateCollectionDto, AddCollectionItemDto } from '@/services/collectionsApi';
import { useAuth } from '@/contexts/AuthContext';
import { toast } from 'sonner';
export const useCollections = () => {
const { user } = useAuth();
const [collections, setCollections] = useState<Collection[]>([]);
const [stats, setStats] = useState<CollectionStats | null>(null);
const [selectedCollection, setSelectedCollection] = useState<Collection | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
// Cargar colecciones
const loadCollections = useCallback(async () => {
if (!user?.id) return;
try {
setLoading(true);
setError(null);
const data = await collectionsApi.getMyCollections();
setCollections(data);
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Error al cargar colecciones';
setError(errorMessage);
console.error('Error loading collections:', err);
} finally {
setLoading(false);
}
}, [user?.id]);
// Cargar estadísticas
const loadStats = useCallback(async () => {
if (!user?.id) return;
try {
const data = await collectionsApi.getCollectionsStats();
setStats(data);
} catch (err) {
console.error('Error loading collections stats:', err);
}
}, [user?.id]);
// Crear colección
const createCollection = useCallback(async (data: CreateCollectionDto): Promise<Collection | null> => {
if (!user?.id) {
toast.error('Inicia sesión para crear colecciones');
return null;
}
try {
const newCollection = await collectionsApi.createCollection(data);
toast.success('Colección creada');
await loadCollections();
await loadStats();
return newCollection;
} catch (err) {
console.error('Error creating collection:', err);
toast.error('Error al crear colección');
return null;
}
}, [user?.id, loadCollections, loadStats]);
// Obtener colección por ID
const getCollectionById = useCallback(async (id: string): Promise<Collection | null> => {
try {
const collection = await collectionsApi.getCollectionById(id);
setSelectedCollection(collection);
return collection;
} catch (err) {
console.error('Error fetching collection:', err);
return null;
}
}, []);
// Actualizar colección
const updateCollection = useCallback(async (id: string, data: UpdateCollectionDto): Promise<boolean> => {
if (!user?.id) {
toast.error('Inicia sesión para actualizar colecciones');
return false;
}
try {
await collectionsApi.updateCollection(id, data);
toast.success('Colección actualizada');
await loadCollections();
return true;
} catch (err) {
console.error('Error updating collection:', err);
toast.error('Error al actualizar colección');
return false;
}
}, [user?.id, loadCollections]);
// Eliminar colección
const deleteCollection = useCallback(async (id: string): Promise<boolean> => {
if (!user?.id) {
toast.error('Inicia sesión para eliminar colecciones');
return false;
}
try {
await collectionsApi.deleteCollection(id);
toast.success('Colección eliminada');
setCollections(prev => prev.filter(c => c.id !== id));
await loadStats();
return true;
} catch (err) {
console.error('Error deleting collection:', err);
toast.error('Error al eliminar colección');
return false;
}
}, [user?.id, loadStats]);
// Agregar item a colección
const addItemToCollection = useCallback(async (collectionId: string, data: AddCollectionItemDto): Promise<boolean> => {
if (!user?.id) {
toast.error('Inicia sesión para agregar items');
return false;
}
try {
await collectionsApi.addItemToCollection(collectionId, data);
toast.success('Item agregado a la colección');
// Recargar la colección si está seleccionada
if (selectedCollection?.id === collectionId) {
await getCollectionById(collectionId);
}
await loadCollections();
return true;
} catch (err) {
console.error('Error adding item to collection:', err);
toast.error('Error al agregar item');
return false;
}
}, [user?.id, selectedCollection?.id, getCollectionById, loadCollections]);
// Quitar item de colección
const removeItemFromCollection = useCallback(async (collectionId: string, itemId: string): Promise<boolean> => {
if (!user?.id) {
toast.error('Inicia sesión para quitar items');
return false;
}
try {
await collectionsApi.removeItemFromCollection(collectionId, itemId);
toast.success('Item eliminado de la colección');
// Actualizar colección seleccionada
if (selectedCollection?.id === collectionId) {
await getCollectionById(collectionId);
}
await loadCollections();
return true;
} catch (err) {
console.error('Error removing item from collection:', err);
toast.error('Error al eliminar item');
return false;
}
}, [user?.id, selectedCollection?.id, getCollectionById, loadCollections]);
// Reordenar items
const reorderItems = useCallback(async (collectionId: string, itemIds: string[]): Promise<boolean> => {
try {
await collectionsApi.reorderCollectionItems(collectionId, itemIds);
if (selectedCollection?.id === collectionId) {
await getCollectionById(collectionId);
}
return true;
} catch (err) {
console.error('Error reordering items:', err);
toast.error('Error al reordenar items');
return false;
}
}, [selectedCollection?.id, getCollectionById]);
// Reordenar colecciones
const reorderCollections = useCallback(async (collectionIds: string[]): Promise<boolean> => {
try {
await collectionsApi.reorderCollections(collectionIds);
await loadCollections();
return true;
} catch (err) {
console.error('Error reordering collections:', err);
toast.error('Error al reordenar colecciones');
return false;
}
}, [loadCollections]);
// Limpiar error
const clearError = useCallback(() => {
setError(null);
}, []);
// Carga inicial
useEffect(() => {
if (user?.id) {
loadCollections();
loadStats();
}
}, [user?.id, loadCollections, loadStats]);
return {
collections,
stats,
selectedCollection,
loading,
error,
loadCollections,
loadStats,
createCollection,
getCollectionById,
updateCollection,
deleteCollection,
addItemToCollection,
removeItemFromCollection,
reorderItems,
reorderCollections,
setSelectedCollection,
clearError,
getCollectionsCount: () => collections.length,
};
};
export default useCollections;

View File

@@ -11,7 +11,6 @@ export const useEmergencyData = () => {
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
// Check if user has emergency permissions
const isOfficer = user?.role === 'politur' || user?.role === 'admin' || user?.role === 'super_admin';
const isAdmin = user?.role === 'admin' || user?.role === 'super_admin';

176
src/hooks/useFavorites.ts Normal file
View File

@@ -0,0 +1,176 @@
import { useState, useEffect, useCallback } from 'react';
import { favoritesApi, Favorite, FavoriteItemType, CreateFavoriteDto, FavoritesCounts } from '@/services/favoritesApi';
import { useAuth } from '@/contexts/AuthContext';
import { toast } from 'sonner';
export const useFavorites = (initialItemType?: FavoriteItemType) => {
const { user } = useAuth();
const [favorites, setFavorites] = useState<Favorite[]>([]);
const [counts, setCounts] = useState<FavoritesCounts | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [selectedType, setSelectedType] = useState<FavoriteItemType | undefined>(initialItemType);
// Cargar favoritos
const loadFavorites = useCallback(async (itemType?: FavoriteItemType) => {
if (!user?.id) return;
try {
setLoading(true);
setError(null);
const data = await favoritesApi.getMyFavorites(itemType || selectedType);
setFavorites(data);
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Error al cargar favoritos';
setError(errorMessage);
console.error('Error loading favorites:', err);
} finally {
setLoading(false);
}
}, [user?.id, selectedType]);
// Cargar conteos
const loadCounts = useCallback(async () => {
if (!user?.id) return;
try {
const data = await favoritesApi.getFavoritesCounts();
setCounts(data);
} catch (err) {
console.error('Error loading favorites counts:', err);
}
}, [user?.id]);
// Agregar a favoritos
const addFavorite = useCallback(async (data: CreateFavoriteDto): Promise<boolean> => {
if (!user?.id) {
toast.error('Inicia sesión para agregar favoritos');
return false;
}
try {
await favoritesApi.addFavorite(data);
toast.success('Agregado a favoritos');
await loadFavorites();
await loadCounts();
return true;
} catch (err) {
console.error('Error adding favorite:', err);
toast.error('Error al agregar favorito');
return false;
}
}, [user?.id, loadFavorites, loadCounts]);
// Toggle favorito
const toggleFavorite = useCallback(async (data: CreateFavoriteDto): Promise<boolean> => {
if (!user?.id) {
toast.error('Inicia sesión para gestionar favoritos');
return false;
}
try {
const result = await favoritesApi.toggleFavorite(data);
if (result.action === 'added') {
toast.success('Agregado a favoritos');
} else {
toast.success('Eliminado de favoritos');
}
await loadFavorites();
await loadCounts();
return true;
} catch (err) {
console.error('Error toggling favorite:', err);
toast.error('Error al actualizar favorito');
return false;
}
}, [user?.id, loadFavorites, loadCounts]);
// Eliminar favorito
const removeFavorite = useCallback(async (favoriteId: string): Promise<boolean> => {
if (!user?.id) {
toast.error('Inicia sesión para gestionar favoritos');
return false;
}
try {
await favoritesApi.removeFavorite(favoriteId);
toast.success('Eliminado de favoritos');
setFavorites(prev => prev.filter(f => f.id !== favoriteId));
await loadCounts();
return true;
} catch (err) {
console.error('Error removing favorite:', err);
toast.error('Error al eliminar favorito');
return false;
}
}, [user?.id, loadCounts]);
// Verificar si es favorito
const checkFavorite = useCallback(async (itemType: FavoriteItemType, itemId: string): Promise<boolean> => {
if (!user?.id) return false;
try {
const result = await favoritesApi.checkFavorite(itemType, itemId);
return result.isFavorite;
} catch (err) {
console.error('Error checking favorite:', err);
return false;
}
}, [user?.id]);
// Verificar si un item está en los favoritos cargados
const isFavorite = useCallback((itemId: string, itemType?: FavoriteItemType): boolean => {
return favorites.some(f => f.itemId === itemId && (!itemType || f.itemType === itemType));
}, [favorites]);
// Obtener favorito por ID
const getFavoriteById = useCallback((favoriteId: string): Favorite | undefined => {
return favorites.find(f => f.id === favoriteId);
}, [favorites]);
// Filtrar por tipo
const filterByType = useCallback((type: FavoriteItemType): Favorite[] => {
return favorites.filter(f => f.itemType === type);
}, [favorites]);
// Cambiar tipo seleccionado
const changeType = useCallback((type?: FavoriteItemType) => {
setSelectedType(type);
loadFavorites(type);
}, [loadFavorites]);
// Limpiar error
const clearError = useCallback(() => {
setError(null);
}, []);
// Carga inicial
useEffect(() => {
if (user?.id) {
loadFavorites();
loadCounts();
}
}, [user?.id, loadFavorites, loadCounts]);
return {
favorites,
counts,
loading,
error,
selectedType,
loadFavorites,
loadCounts,
addFavorite,
toggleFavorite,
removeFavorite,
checkFavorite,
isFavorite,
getFavoriteById,
filterByType,
changeType,
clearError,
getFavoritesCount: () => favorites.length,
};
};
export default useFavorites;

View File

@@ -0,0 +1,177 @@
/**
* useNotifications Hook
* Hook para gestionar notificaciones en el Dashboard - Fase 2
*/
import { useState, useEffect, useCallback } from 'react';
import { notificationsApi, Notification, NotificationType, NotificationStats } from '@/services/notificationsApi';
import { useToast } from '@/hooks/useToast';
interface UseNotificationsOptions {
autoFetch?: boolean;
pollingInterval?: number; // en milisegundos, 0 = sin polling
}
interface UseNotificationsReturn {
notifications: Notification[];
unreadCount: number;
stats: NotificationStats | null;
loading: boolean;
error: string | null;
// Actions
fetchNotifications: (type?: NotificationType, isRead?: boolean) => Promise<void>;
markAsRead: (notificationId: string) => Promise<void>;
markAllAsRead: () => Promise<void>;
deleteNotification: (notificationId: string) => Promise<void>;
deleteAllRead: () => Promise<void>;
refresh: () => Promise<void>;
}
export function useNotifications(options: UseNotificationsOptions = {}): UseNotificationsReturn {
const { autoFetch = true, pollingInterval = 0 } = options;
const { toast } = useToast();
const [notifications, setNotifications] = useState<Notification[]>([]);
const [unreadCount, setUnreadCount] = useState(0);
const [stats, setStats] = useState<NotificationStats | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const fetchNotifications = useCallback(async (type?: NotificationType, isRead?: boolean) => {
setLoading(true);
setError(null);
try {
const result = await notificationsApi.getMyNotifications({ type, isRead, limit: 50 });
setNotifications(result.notifications);
setUnreadCount(result.unreadCount);
} catch (err) {
const message = err instanceof Error ? err.message : 'Error al cargar notificaciones';
setError(message);
console.error('Error fetching notifications:', err);
} finally {
setLoading(false);
}
}, []);
const fetchUnreadCount = useCallback(async () => {
try {
const count = await notificationsApi.getUnreadCount();
setUnreadCount(count);
} catch (err) {
console.error('Error fetching unread count:', err);
}
}, []);
const markAsRead = useCallback(async (notificationId: string) => {
try {
await notificationsApi.markAsRead(notificationId);
setNotifications(prev =>
prev.map(n => n.id === notificationId ? { ...n, isRead: true, readAt: new Date().toISOString() } : n)
);
setUnreadCount(prev => Math.max(0, prev - 1));
} catch (err) {
toast({
title: 'Error',
description: 'No se pudo marcar la notificacion como leida',
variant: 'destructive',
});
throw err;
}
}, [toast]);
const markAllAsRead = useCallback(async () => {
try {
const result = await notificationsApi.markAllAsRead();
setNotifications(prev =>
prev.map(n => ({ ...n, isRead: true, readAt: new Date().toISOString() }))
);
setUnreadCount(0);
toast({
title: 'Listo',
description: `${result.count} notificaciones marcadas como leidas`,
});
} catch (err) {
toast({
title: 'Error',
description: 'No se pudieron marcar las notificaciones como leidas',
variant: 'destructive',
});
throw err;
}
}, [toast]);
const deleteNotification = useCallback(async (notificationId: string) => {
try {
const notification = notifications.find(n => n.id === notificationId);
await notificationsApi.deleteNotification(notificationId);
setNotifications(prev => prev.filter(n => n.id !== notificationId));
if (notification && !notification.isRead) {
setUnreadCount(prev => Math.max(0, prev - 1));
}
toast({
title: 'Eliminada',
description: 'Notificacion eliminada correctamente',
});
} catch (err) {
toast({
title: 'Error',
description: 'No se pudo eliminar la notificacion',
variant: 'destructive',
});
throw err;
}
}, [notifications, toast]);
const deleteAllRead = useCallback(async () => {
try {
const result = await notificationsApi.deleteAllRead();
setNotifications(prev => prev.filter(n => !n.isRead));
toast({
title: 'Listo',
description: `${result.count} notificaciones eliminadas`,
});
} catch (err) {
toast({
title: 'Error',
description: 'No se pudieron eliminar las notificaciones',
variant: 'destructive',
});
throw err;
}
}, [toast]);
const refresh = useCallback(async () => {
await fetchNotifications();
}, [fetchNotifications]);
// Auto fetch on mount
useEffect(() => {
if (autoFetch) {
fetchNotifications();
}
}, [autoFetch, fetchNotifications]);
// Polling for unread count
useEffect(() => {
if (pollingInterval > 0) {
const interval = setInterval(fetchUnreadCount, pollingInterval);
return () => clearInterval(interval);
}
}, [pollingInterval, fetchUnreadCount]);
return {
notifications,
unreadCount,
stats,
loading,
error,
fetchNotifications,
markAsRead,
markAllAsRead,
deleteNotification,
deleteAllRead,
refresh,
};
}
export default useNotifications;

211
src/hooks/useQuiz.ts Normal file
View File

@@ -0,0 +1,211 @@
import { useState, useEffect, useCallback } from 'react';
import { quizApi, QuizQuestion, QuizResponse, SubmitQuizDto, QuizAnswer } from '@/services/quizApi';
import { useAuth } from '@/contexts/AuthContext';
import { toast } from 'sonner';
export const useQuiz = () => {
const { user } = useAuth();
const [questions, setQuestions] = useState<QuizQuestion[]>([]);
const [quizResponse, setQuizResponse] = useState<QuizResponse | null>(null);
const [currentAnswers, setCurrentAnswers] = useState<QuizAnswer[]>([]);
const [currentQuestionIndex, setCurrentQuestionIndex] = useState(0);
const [loading, setLoading] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
// Cargar preguntas
const loadQuestions = useCallback(async () => {
try {
setLoading(true);
setError(null);
const data = await quizApi.getQuestions();
setQuestions(data);
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Error al cargar preguntas';
setError(errorMessage);
console.error('Error loading questions:', err);
} finally {
setLoading(false);
}
}, []);
// Cargar respuesta del usuario
const loadMyResponse = useCallback(async () => {
if (!user?.id) return;
try {
const data = await quizApi.getMyQuizResponse();
setQuizResponse(data);
} catch (err) {
console.error('Error loading quiz response:', err);
}
}, [user?.id]);
// Responder pregunta actual
const answerQuestion = useCallback((questionId: string, selectedOptions: string[]) => {
setCurrentAnswers(prev => {
const existing = prev.findIndex(a => a.questionId === questionId);
if (existing >= 0) {
const updated = [...prev];
updated[existing] = { questionId, selectedOptions };
return updated;
}
return [...prev, { questionId, selectedOptions }];
});
}, []);
// Ir a siguiente pregunta
const nextQuestion = useCallback(() => {
if (currentQuestionIndex < questions.length - 1) {
setCurrentQuestionIndex(prev => prev + 1);
}
}, [currentQuestionIndex, questions.length]);
// Ir a pregunta anterior
const prevQuestion = useCallback(() => {
if (currentQuestionIndex > 0) {
setCurrentQuestionIndex(prev => prev - 1);
}
}, [currentQuestionIndex]);
// Ir a pregunta específica
const goToQuestion = useCallback((index: number) => {
if (index >= 0 && index < questions.length) {
setCurrentQuestionIndex(index);
}
}, [questions.length]);
// Enviar quiz
const submitQuiz = useCallback(async (): Promise<QuizResponse | null> => {
if (!user?.id) {
toast.error('Inicia sesión para completar el quiz');
return null;
}
// Verificar que todas las preguntas estén respondidas
const unanswered = questions.filter(
q => !currentAnswers.find(a => a.questionId === q.id)
);
if (unanswered.length > 0) {
toast.error(`Faltan ${unanswered.length} preguntas por responder`);
return null;
}
try {
setSubmitting(true);
const response = await quizApi.submitQuiz({ answers: currentAnswers });
setQuizResponse(response);
toast.success(`¡Quiz completado! Tu Travel Persona es: ${response.travelPersona}`);
return response;
} catch (err) {
console.error('Error submitting quiz:', err);
toast.error('Error al enviar quiz');
return null;
} finally {
setSubmitting(false);
}
}, [user?.id, questions, currentAnswers]);
// Reiniciar quiz
const resetQuiz = useCallback(async (): Promise<boolean> => {
if (!user?.id) {
toast.error('Inicia sesión para reiniciar el quiz');
return false;
}
try {
await quizApi.resetQuiz();
setQuizResponse(null);
setCurrentAnswers([]);
setCurrentQuestionIndex(0);
toast.success('Quiz reiniciado');
return true;
} catch (err) {
console.error('Error resetting quiz:', err);
toast.error('Error al reiniciar quiz');
return false;
}
}, [user?.id]);
// Verificar si el quiz está completado
const isCompleted = useCallback((): boolean => {
return quizResponse?.isCompleted ?? false;
}, [quizResponse]);
// Obtener la Travel Persona
const getTravelPersona = useCallback(() => {
if (quizResponse?.isCompleted && quizResponse.travelPersona) {
return {
persona: quizResponse.travelPersona,
description: quizResponse.personaDescription,
};
}
return null;
}, [quizResponse]);
// Obtener respuesta de una pregunta
const getAnswer = useCallback((questionId: string): string[] | undefined => {
return currentAnswers.find(a => a.questionId === questionId)?.selectedOptions;
}, [currentAnswers]);
// Verificar si una pregunta está respondida
const isAnswered = useCallback((questionId: string): boolean => {
return currentAnswers.some(a => a.questionId === questionId && a.selectedOptions.length > 0);
}, [currentAnswers]);
// Obtener progreso del quiz
const getProgress = useCallback(() => {
const answered = currentAnswers.filter(a => a.selectedOptions.length > 0).length;
return {
answered,
total: questions.length,
percentage: questions.length > 0 ? Math.round((answered / questions.length) * 100) : 0,
};
}, [currentAnswers, questions.length]);
// Limpiar error
const clearError = useCallback(() => {
setError(null);
}, []);
// Pregunta actual
const currentQuestion = questions[currentQuestionIndex] || null;
// Carga inicial
useEffect(() => {
loadQuestions();
if (user?.id) {
loadMyResponse();
}
}, [loadQuestions, user?.id, loadMyResponse]);
return {
questions,
quizResponse,
currentQuestion,
currentQuestionIndex,
currentAnswers,
loading,
submitting,
error,
loadQuestions,
loadMyResponse,
answerQuestion,
nextQuestion,
prevQuestion,
goToQuestion,
submitQuiz,
resetQuiz,
isCompleted,
getTravelPersona,
getAnswer,
isAnswered,
getProgress,
clearError,
isFirstQuestion: currentQuestionIndex === 0,
isLastQuestion: currentQuestionIndex === questions.length - 1,
};
};
export default useQuiz;

View File

@@ -0,0 +1,75 @@
import { useState, useEffect } from 'react';
export interface ResponsiveBreakpoints {
isMobile: boolean;
isTablet: boolean;
isDesktop: boolean;
isLargeDesktop: boolean;
width: number;
}
const BREAKPOINTS = {
mobile: 768,
tablet: 1024,
desktop: 1280,
} as const;
/**
* Hook for responsive breakpoint detection
* Provides granular control over responsive behavior
*/
export function useResponsive(): ResponsiveBreakpoints {
const [breakpoints, setBreakpoints] = useState<ResponsiveBreakpoints>({
isMobile: false,
isTablet: false,
isDesktop: false,
isLargeDesktop: false,
width: typeof window !== 'undefined' ? window.innerWidth : 0,
});
useEffect(() => {
const handleResize = () => {
const width = window.innerWidth;
setBreakpoints({
isMobile: width < BREAKPOINTS.mobile,
isTablet: width >= BREAKPOINTS.mobile && width < BREAKPOINTS.tablet,
isDesktop: width >= BREAKPOINTS.tablet && width < BREAKPOINTS.desktop,
isLargeDesktop: width >= BREAKPOINTS.desktop,
width,
});
};
// Initial check
handleResize();
// Listen for resize events
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, []);
return breakpoints;
}
/**
* Hook for orientation detection
*/
export function useOrientation() {
const [orientation, setOrientation] = useState<'portrait' | 'landscape'>('portrait');
useEffect(() => {
const handleOrientationChange = () => {
setOrientation(
window.innerHeight > window.innerWidth ? 'portrait' : 'landscape'
);
};
handleOrientationChange();
window.addEventListener('resize', handleOrientationChange);
return () => window.removeEventListener('resize', handleOrientationChange);
}, []);
return orientation;
}

View File

@@ -0,0 +1,172 @@
import { useState, useEffect } from 'react';
import { Role, EntityType, PERMISSIONS, UserRole } from '@/types/roles';
import { useToast } from '@/hooks/use-toast';
const MOCK_ROLES: Role[] = [
{
id: 'admin-super',
name: 'Super Admin',
entityType: 'admin',
description: 'Full system access',
permissions: PERMISSIONS.admin.map(p => p.id),
userCount: 2,
isSystem: true,
},
{
id: 'admin-moderator',
name: 'Moderator',
entityType: 'admin',
description: 'Content and user management',
permissions: ['admin.users.read', 'admin.content.write'],
userCount: 5,
},
{
id: 'hotel-manager',
name: 'Hotel Manager',
entityType: 'hotel',
description: 'Full hotel management access',
permissions: PERMISSIONS.hotel.map(p => p.id),
userCount: 8,
isSystem: true,
},
{
id: 'hotel-receptionist',
name: 'Receptionist',
entityType: 'hotel',
description: 'Handle bookings and guests',
permissions: ['hotel.bookings.read', 'hotel.bookings.write', 'hotel.guests.read'],
userCount: 15,
},
{
id: 'restaurant-manager',
name: 'Restaurant Manager',
entityType: 'restaurant',
description: 'Full restaurant management',
permissions: PERMISSIONS.restaurant.map(p => p.id),
userCount: 6,
isSystem: true,
},
{
id: 'restaurant-waiter',
name: 'Waiter',
entityType: 'restaurant',
description: 'Take and manage orders',
permissions: ['restaurant.orders.read', 'restaurant.orders.write', 'restaurant.reservations.write'],
userCount: 20,
},
{
id: 'commerce-admin',
name: 'Commerce Admin',
entityType: 'commerce',
description: 'Full commerce access',
permissions: PERMISSIONS.commerce.map(p => p.id),
userCount: 4,
isSystem: true,
},
{
id: 'commerce-operator',
name: 'Store Operator',
entityType: 'commerce',
description: 'Manage products and orders',
permissions: ['commerce.products.read', 'commerce.orders.read', 'commerce.orders.write'],
userCount: 12,
},
];
export const useRolesPermissions = () => {
const [roles, setRoles] = useState<Role[]>(MOCK_ROLES);
const [userRoles, setUserRoles] = useState<UserRole[]>([]);
const [loading, setLoading] = useState(false);
const { toast } = useToast();
const getRolesByEntity = (entityType: EntityType): Role[] => {
return roles.filter(role => role.entityType === entityType);
};
const createRole = (role: Omit<Role, 'id' | 'userCount'>): void => {
const newRole: Role = {
...role,
id: `${role.entityType}-${Date.now()}`,
userCount: 0,
};
setRoles([...roles, newRole]);
toast({
title: 'Role Created',
description: `${role.name} has been created successfully.`,
});
};
const updateRole = (roleId: string, updates: Partial<Role>): void => {
setRoles(roles.map(role =>
role.id === roleId ? { ...role, ...updates } : role
));
toast({
title: 'Role Updated',
description: 'Role has been updated successfully.',
});
};
const deleteRole = (roleId: string): void => {
const role = roles.find(r => r.id === roleId);
if (role?.isSystem) {
toast({
title: 'Cannot Delete',
description: 'System roles cannot be deleted.',
variant: 'destructive',
});
return;
}
setRoles(roles.filter(role => role.id !== roleId));
toast({
title: 'Role Deleted',
description: 'Role has been deleted successfully.',
});
};
const assignUserRole = (userId: string, roleId: string, entityId?: string): void => {
const newUserRole: UserRole = {
userId,
roleId,
entityId,
assignedAt: new Date().toISOString(),
assignedBy: 'current-user',
};
setUserRoles([...userRoles, newUserRole]);
const role = roles.find(r => r.id === roleId);
if (role) {
updateRole(roleId, { userCount: role.userCount + 1 });
}
toast({
title: 'Role Assigned',
description: 'User role has been assigned successfully.',
});
};
const removeUserRole = (userId: string, roleId: string): void => {
setUserRoles(userRoles.filter(ur => !(ur.userId === userId && ur.roleId === roleId)));
const role = roles.find(r => r.id === roleId);
if (role && role.userCount > 0) {
updateRole(roleId, { userCount: role.userCount - 1 });
}
toast({
title: 'Role Removed',
description: 'User role has been removed successfully.',
});
};
return {
roles,
userRoles,
loading,
getRolesByEntity,
createRole,
updateRole,
deleteRole,
assignUserRole,
removeUserRole,
};
};

289
src/hooks/useTrips.ts Normal file
View File

@@ -0,0 +1,289 @@
import { useState, useEffect, useCallback } from 'react';
import { tripsApi, Trip, TripStatus, TripStats, TripDay, TripActivity, CreateTripDto, UpdateTripDto, CreateTripDayDto, UpdateTripDayDto, CreateTripActivityDto, UpdateTripActivityDto } from '@/services/tripsApi';
import { useAuth } from '@/contexts/AuthContext';
import { toast } from 'sonner';
export const useTrips = (initialStatus?: TripStatus) => {
const { user } = useAuth();
const [trips, setTrips] = useState<Trip[]>([]);
const [stats, setStats] = useState<TripStats | null>(null);
const [selectedTrip, setSelectedTrip] = useState<Trip | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [selectedStatus, setSelectedStatus] = useState<TripStatus | undefined>(initialStatus);
// Cargar viajes
const loadTrips = useCallback(async (status?: TripStatus) => {
if (!user?.id) return;
try {
setLoading(true);
setError(null);
const data = await tripsApi.getMyTrips(status || selectedStatus);
setTrips(data);
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Error al cargar viajes';
setError(errorMessage);
console.error('Error loading trips:', err);
} finally {
setLoading(false);
}
}, [user?.id, selectedStatus]);
// Cargar estadísticas
const loadStats = useCallback(async () => {
if (!user?.id) return;
try {
const data = await tripsApi.getTripsStats();
setStats(data);
} catch (err) {
console.error('Error loading trips stats:', err);
}
}, [user?.id]);
// Crear viaje
const createTrip = useCallback(async (data: CreateTripDto): Promise<Trip | null> => {
if (!user?.id) {
toast.error('Inicia sesión para crear viajes');
return null;
}
try {
const newTrip = await tripsApi.createTrip(data);
toast.success('Viaje creado');
await loadTrips();
await loadStats();
return newTrip;
} catch (err) {
console.error('Error creating trip:', err);
toast.error('Error al crear viaje');
return null;
}
}, [user?.id, loadTrips, loadStats]);
// Obtener viaje por ID
const getTripById = useCallback(async (id: string): Promise<Trip | null> => {
try {
const trip = await tripsApi.getTripById(id);
setSelectedTrip(trip);
return trip;
} catch (err) {
console.error('Error fetching trip:', err);
return null;
}
}, []);
// Actualizar viaje
const updateTrip = useCallback(async (id: string, data: UpdateTripDto): Promise<boolean> => {
if (!user?.id) {
toast.error('Inicia sesión para actualizar viajes');
return false;
}
try {
const updated = await tripsApi.updateTrip(id, data);
toast.success('Viaje actualizado');
if (selectedTrip?.id === id) {
setSelectedTrip(updated);
}
await loadTrips();
return true;
} catch (err) {
console.error('Error updating trip:', err);
toast.error('Error al actualizar viaje');
return false;
}
}, [user?.id, selectedTrip?.id, loadTrips]);
// Eliminar viaje
const deleteTrip = useCallback(async (id: string): Promise<boolean> => {
if (!user?.id) {
toast.error('Inicia sesión para eliminar viajes');
return false;
}
try {
await tripsApi.deleteTrip(id);
toast.success('Viaje eliminado');
setTrips(prev => prev.filter(t => t.id !== id));
if (selectedTrip?.id === id) {
setSelectedTrip(null);
}
await loadStats();
return true;
} catch (err) {
console.error('Error deleting trip:', err);
toast.error('Error al eliminar viaje');
return false;
}
}, [user?.id, selectedTrip?.id, loadStats]);
// ============ DAYS ============
// Agregar día
const addDay = useCallback(async (tripId: string, data: CreateTripDayDto): Promise<TripDay | null> => {
try {
const newDay = await tripsApi.addDay(tripId, data);
toast.success('Día agregado');
if (selectedTrip?.id === tripId) {
await getTripById(tripId);
}
return newDay;
} catch (err) {
console.error('Error adding day:', err);
toast.error('Error al agregar día');
return null;
}
}, [selectedTrip?.id, getTripById]);
// Actualizar día
const updateDay = useCallback(async (tripId: string, dayId: string, data: UpdateTripDayDto): Promise<boolean> => {
try {
await tripsApi.updateDay(tripId, dayId, data);
toast.success('Día actualizado');
if (selectedTrip?.id === tripId) {
await getTripById(tripId);
}
return true;
} catch (err) {
console.error('Error updating day:', err);
toast.error('Error al actualizar día');
return false;
}
}, [selectedTrip?.id, getTripById]);
// Eliminar día
const deleteDay = useCallback(async (tripId: string, dayId: string): Promise<boolean> => {
try {
await tripsApi.deleteDay(tripId, dayId);
toast.success('Día eliminado');
if (selectedTrip?.id === tripId) {
await getTripById(tripId);
}
return true;
} catch (err) {
console.error('Error deleting day:', err);
toast.error('Error al eliminar día');
return false;
}
}, [selectedTrip?.id, getTripById]);
// ============ ACTIVITIES ============
// Agregar actividad
const addActivity = useCallback(async (tripId: string, dayId: string, data: CreateTripActivityDto): Promise<TripActivity | null> => {
try {
const newActivity = await tripsApi.addActivity(tripId, dayId, data);
toast.success('Actividad agregada');
if (selectedTrip?.id === tripId) {
await getTripById(tripId);
}
return newActivity;
} catch (err) {
console.error('Error adding activity:', err);
toast.error('Error al agregar actividad');
return null;
}
}, [selectedTrip?.id, getTripById]);
// Actualizar actividad
const updateActivity = useCallback(async (tripId: string, dayId: string, activityId: string, data: UpdateTripActivityDto): Promise<boolean> => {
try {
await tripsApi.updateActivity(tripId, dayId, activityId, data);
toast.success('Actividad actualizada');
if (selectedTrip?.id === tripId) {
await getTripById(tripId);
}
return true;
} catch (err) {
console.error('Error updating activity:', err);
toast.error('Error al actualizar actividad');
return false;
}
}, [selectedTrip?.id, getTripById]);
// Eliminar actividad
const deleteActivity = useCallback(async (tripId: string, dayId: string, activityId: string): Promise<boolean> => {
try {
await tripsApi.deleteActivity(tripId, dayId, activityId);
toast.success('Actividad eliminada');
if (selectedTrip?.id === tripId) {
await getTripById(tripId);
}
return true;
} catch (err) {
console.error('Error deleting activity:', err);
toast.error('Error al eliminar actividad');
return false;
}
}, [selectedTrip?.id, getTripById]);
// Reordenar actividades
const reorderActivities = useCallback(async (tripId: string, dayId: string, activityIds: string[]): Promise<boolean> => {
try {
await tripsApi.reorderActivities(tripId, dayId, activityIds);
if (selectedTrip?.id === tripId) {
await getTripById(tripId);
}
return true;
} catch (err) {
console.error('Error reordering activities:', err);
toast.error('Error al reordenar actividades');
return false;
}
}, [selectedTrip?.id, getTripById]);
// Cambiar estado seleccionado
const changeStatus = useCallback((status?: TripStatus) => {
setSelectedStatus(status);
loadTrips(status);
}, [loadTrips]);
// Filtrar por estado
const filterByStatus = useCallback((status: TripStatus): Trip[] => {
return trips.filter(t => t.status === status);
}, [trips]);
// Limpiar error
const clearError = useCallback(() => {
setError(null);
}, []);
// Carga inicial
useEffect(() => {
if (user?.id) {
loadTrips();
loadStats();
}
}, [user?.id, loadTrips, loadStats]);
return {
trips,
stats,
selectedTrip,
loading,
error,
selectedStatus,
loadTrips,
loadStats,
createTrip,
getTripById,
updateTrip,
deleteTrip,
addDay,
updateDay,
deleteDay,
addActivity,
updateActivity,
deleteActivity,
reorderActivities,
changeStatus,
filterByStatus,
setSelectedTrip,
clearError,
getTripsCount: () => trips.length,
};
};
export default useTrips;

133
src/lib/validation.ts Normal file
View File

@@ -0,0 +1,133 @@
import { z } from 'zod';
/**
* SECURITY: Input validation schemas using zod
* All user inputs must be validated to prevent injection attacks
*/
// Authentication schemas
export const loginSchema = z.object({
email: z
.string()
.trim()
.min(1, 'El email es requerido')
.email('Email inválido')
.max(255, 'Email demasiado largo'),
password: z
.string()
.min(6, 'La contraseña debe tener al menos 6 caracteres')
.max(128, 'Contraseña demasiado larga'),
});
export const registerSchema = z.object({
fullName: z
.string()
.trim()
.min(2, 'El nombre debe tener al menos 2 caracteres')
.max(100, 'Nombre demasiado largo')
.regex(/^[a-zA-ZáéíóúÁÉÍÓÚñÑ\s]+$/, 'El nombre solo puede contener letras'),
email: z
.string()
.trim()
.min(1, 'El email es requerido')
.email('Email inválido')
.max(255, 'Email demasiado largo'),
password: z
.string()
.min(8, 'La contraseña debe tener al menos 8 caracteres')
.max(128, 'Contraseña demasiado larga')
.regex(
/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/,
'La contraseña debe contener al menos una mayúscula, una minúscula y un número'
),
confirmPassword: z.string(),
userType: z.enum(['tourist', 'business'], {
errorMap: () => ({ message: 'Tipo de usuario inválido' }),
}),
}).refine((data) => data.password === data.confirmPassword, {
message: 'Las contraseñas no coinciden',
path: ['confirmPassword'],
});
// User profile update schema
export const profileUpdateSchema = z.object({
name: z
.string()
.trim()
.min(2, 'El nombre debe tener al menos 2 caracteres')
.max(100, 'Nombre demasiado largo')
.regex(/^[a-zA-ZáéíóúÁÉÍÓÚñÑ\s]+$/, 'El nombre solo puede contener letras')
.optional(),
phone: z
.string()
.trim()
.regex(/^\+?[0-9\s\-()]+$/, 'Número de teléfono inválido')
.max(20, 'Número demasiado largo')
.optional(),
address: z
.string()
.trim()
.max(500, 'Dirección demasiado larga')
.optional(),
});
// Contact form schema
export const contactFormSchema = z.object({
name: z
.string()
.trim()
.min(2, 'El nombre debe tener al menos 2 caracteres')
.max(100, 'Nombre demasiado largo'),
email: z
.string()
.trim()
.email('Email inválido')
.max(255, 'Email demasiado largo'),
phone: z
.string()
.trim()
.regex(/^\+?[0-9\s\-()]+$/, 'Número de teléfono inválido')
.max(20, 'Número demasiado largo')
.optional(),
message: z
.string()
.trim()
.min(10, 'El mensaje debe tener al menos 10 caracteres')
.max(2000, 'Mensaje demasiado largo'),
});
// Review schema
export const reviewSchema = z.object({
rating: z
.number()
.min(1, 'La calificación mínima es 1')
.max(5, 'La calificación máxima es 5')
.int('La calificación debe ser un número entero'),
comment: z
.string()
.trim()
.min(10, 'El comentario debe tener al menos 10 caracteres')
.max(1000, 'Comentario demasiado largo')
.optional(),
});
// Search query schema
export const searchSchema = z.object({
query: z
.string()
.trim()
.min(1, 'La búsqueda no puede estar vacía')
.max(200, 'Búsqueda demasiado larga'),
category: z
.string()
.trim()
.max(50, 'Categoría inválida')
.optional(),
});
export type LoginInput = z.infer<typeof loginSchema>;
export type RegisterInput = z.infer<typeof registerSchema>;
export type ProfileUpdateInput = z.infer<typeof profileUpdateSchema>;
export type ContactFormInput = z.infer<typeof contactFormSchema>;
export type ReviewInput = z.infer<typeof reviewSchema>;
export type SearchInput = z.infer<typeof searchSchema>;

View File

@@ -0,0 +1,103 @@
/**
* Middleware for handling responsive API requests
* Ensures backend responds appropriately to different device types
*/
export interface DeviceInfo {
type: 'mobile' | 'tablet' | 'desktop';
userAgent: string;
screenWidth?: number;
screenHeight?: number;
}
/**
* Detect device type from User-Agent and viewport
*/
export function detectDevice(userAgent: string, width?: number): DeviceInfo['type'] {
const mobileRegex = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i;
const tabletRegex = /iPad|Android(?!.*Mobile)/i;
if (width) {
if (width < 768) return 'mobile';
if (width < 1024) return 'tablet';
return 'desktop';
}
if (tabletRegex.test(userAgent)) return 'tablet';
if (mobileRegex.test(userAgent)) return 'mobile';
return 'desktop';
}
/**
* Optimize response payload based on device type
* Reduces data transfer for mobile devices
*/
export function optimizePayload<T extends Record<string, any>>(
data: T,
deviceType: DeviceInfo['type']
): T {
if (deviceType === 'mobile') {
// Remove heavy fields for mobile
const optimized = { ...data } as any;
// Remove high-res images, use thumbnails instead
if ('images' in data && Array.isArray(data.images)) {
optimized.images = (data.images as any[]).map((img: any) => ({
...img,
url: img.thumbnail || img.url,
highRes: undefined,
}));
}
// Limit array sizes for mobile
if ('items' in data && Array.isArray(data.items)) {
optimized.items = (data.items as any[]).slice(0, 10);
}
return optimized;
}
return data;
}
/**
* Add device info to API request headers
*/
export function addDeviceHeaders(headers: HeadersInit = {}): HeadersInit {
const width = typeof window !== 'undefined' ? window.innerWidth : undefined;
const userAgent = typeof navigator !== 'undefined' ? navigator.userAgent : '';
const deviceType = detectDevice(userAgent, width);
return {
...headers,
'X-Device-Type': deviceType,
'X-Screen-Width': width?.toString() || '',
'X-User-Agent': userAgent,
};
}
/**
* Adaptive pagination based on device
*/
export function getAdaptivePagination(deviceType: DeviceInfo['type']) {
const limits = {
mobile: 10,
tablet: 20,
desktop: 50,
};
return {
limit: limits[deviceType],
offset: 0,
};
}
/**
* Image quality based on device and connection
*/
export function getImageQuality(deviceType: DeviceInfo['type'], connectionType?: string): number {
if (connectionType === 'slow-2g' || connectionType === '2g') return 30;
if (deviceType === 'mobile') return 60;
if (deviceType === 'tablet') return 80;
return 90;
}

View File

@@ -6,6 +6,7 @@ import { useAuth } from '@/contexts/AuthContext';
import { useLanguage } from '@/contexts/LanguageContext';
import { Apple, Eye, EyeOff } from 'lucide-react';
import { FaGoogle } from 'react-icons/fa';
import { loginSchema, type LoginInput } from '@/lib/validation';
const SignIn = () => {
const [email, setEmail] = useState('');
@@ -24,8 +25,10 @@ const SignIn = () => {
setIsLoading(true);
setError('');
// SECURITY: Validate inputs before processing
try {
await login(email, password);
const validatedData = loginSchema.parse({ email, password });
await login(validatedData.email, validatedData.password);
// Decide destination based on role
const cached = localStorage.getItem('karibeo-user');
const u = user ?? (cached ? JSON.parse(cached) : null);
@@ -36,7 +39,12 @@ const SignIn = () => {
navigate('/dashboard');
}
} catch (err: any) {
// Handle validation errors from zod
if (err.name === 'ZodError') {
setError(err.errors[0]?.message || 'Datos inválidos');
} else {
setError(err.message);
}
} finally {
setIsLoading(false);
}

View File

@@ -6,6 +6,7 @@ import { useAuth } from '@/contexts/AuthContext';
import { useLanguage } from '@/contexts/LanguageContext';
import { Apple, Eye, EyeOff } from 'lucide-react';
import { FaGoogle } from 'react-icons/fa';
import { registerSchema, type RegisterInput } from '@/lib/validation';
const SignUp = () => {
const [formData, setFormData] = useState({
@@ -36,19 +37,17 @@ const SignUp = () => {
e.preventDefault();
setError('');
if (formData.password !== formData.confirmPassword) {
setError('Las contraseñas no coinciden');
return;
}
if (!agreeToTerms) {
setError('Debes aceptar los términos de servicio');
return;
}
// SECURITY: Validate all inputs before processing
try {
const validatedData = registerSchema.parse(formData);
setIsLoading(true);
try {
await register({
name: formData.fullName,
email: formData.email,
@@ -59,7 +58,12 @@ const SignUp = () => {
});
navigate('/dashboard');
} catch (err: any) {
// Handle validation errors from zod
if (err.name === 'ZodError') {
setError(err.errors[0]?.message || 'Datos inválidos');
} else {
setError(err.message);
}
} finally {
setIsLoading(false);
}

View File

@@ -1,5 +1,4 @@
import { useState } from 'react';
import DashboardLayout from '@/components/DashboardLayout';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { useAnalytics } from '@/hooks/useAnalytics';
@@ -43,7 +42,6 @@ const Analytics = () => {
);
return (
<DashboardLayout>
<div className="p-6 space-y-6">
{/* Header */}
<div className="flex justify-between items-center">
@@ -242,7 +240,6 @@ const Analytics = () => {
</>
)}
</div>
</DashboardLayout>
);
};

View File

@@ -0,0 +1,518 @@
import { useState } from 'react';
import { useCollections } from '@/hooks/useCollections';
import {
FolderHeart,
Plus,
Trash2,
Edit2,
Eye,
Globe,
Lock,
RefreshCw,
Search,
MoreVertical,
Image as ImageIcon,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Input } from '@/components/ui/input';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { Switch } from '@/components/ui/switch';
const Collections = () => {
const {
collections,
stats,
loading,
error,
loadCollections,
createCollection,
updateCollection,
deleteCollection,
getCollectionById,
selectedCollection,
setSelectedCollection,
} = useCollections();
const [searchTerm, setSearchTerm] = useState('');
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false);
const [isEditDialogOpen, setIsEditDialogOpen] = useState(false);
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
const [collectionToDelete, setCollectionToDelete] = useState<string | null>(null);
// Form state
const [formData, setFormData] = useState({
name: '',
description: '',
coverImageUrl: '',
color: '#3b82f6',
isPublic: false,
});
const filteredCollections = collections.filter(col =>
col.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
col.description?.toLowerCase().includes(searchTerm.toLowerCase())
);
const handleRefresh = async () => {
await loadCollections();
};
const handleCreate = async () => {
const result = await createCollection(formData);
if (result) {
setIsCreateDialogOpen(false);
resetForm();
}
};
const handleEdit = async () => {
if (!selectedCollection) return;
const success = await updateCollection(selectedCollection.id, formData);
if (success) {
setIsEditDialogOpen(false);
resetForm();
}
};
const handleDelete = async () => {
if (!collectionToDelete) return;
const success = await deleteCollection(collectionToDelete);
if (success) {
setIsDeleteDialogOpen(false);
setCollectionToDelete(null);
}
};
const openEditDialog = async (collectionId: string) => {
const collection = await getCollectionById(collectionId);
if (collection) {
setFormData({
name: collection.name,
description: collection.description || '',
coverImageUrl: collection.coverImageUrl || '',
color: collection.color || '#3b82f6',
isPublic: collection.isPublic,
});
setIsEditDialogOpen(true);
}
};
const openDeleteDialog = (collectionId: string) => {
setCollectionToDelete(collectionId);
setIsDeleteDialogOpen(true);
};
const resetForm = () => {
setFormData({
name: '',
description: '',
coverImageUrl: '',
color: '#3b82f6',
isPublic: false,
});
setSelectedCollection(null);
};
if (loading && collections.length === 0) {
return (
<div className="body-content">
<div className="container-xxl">
<div className="flex items-center justify-center min-h-96">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary mx-auto"></div>
<p className="mt-4 text-muted-foreground">Cargando colecciones...</p>
</div>
</div>
</div>
</div>
);
}
return (
<div className="body-content">
<div className="container-xxl">
{/* Header */}
<div className="flex flex-col md:flex-row md:items-center md:justify-between mb-6">
<div>
<h1 className="text-2xl font-bold flex items-center gap-2">
<FolderHeart className="h-6 w-6 text-primary" />
Gestión de Colecciones
</h1>
<p className="text-muted-foreground mt-1">
Administra las colecciones de lugares favoritos
</p>
</div>
<div className="flex gap-2 mt-4 md:mt-0">
<Button onClick={handleRefresh} variant="outline">
<RefreshCw className={`h-4 w-4 mr-2 ${loading ? 'animate-spin' : ''}`} />
Actualizar
</Button>
<Dialog open={isCreateDialogOpen} onOpenChange={setIsCreateDialogOpen}>
<DialogTrigger asChild>
<Button>
<Plus className="h-4 w-4 mr-2" />
Nueva Colección
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Crear Colección</DialogTitle>
<DialogDescription>
Crea una nueva colección para organizar lugares.
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label htmlFor="name">Nombre</Label>
<Input
id="name"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
placeholder="Mi colección"
/>
</div>
<div className="space-y-2">
<Label htmlFor="description">Descripción</Label>
<Textarea
id="description"
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
placeholder="Describe tu colección..."
/>
</div>
<div className="space-y-2">
<Label htmlFor="coverImage">URL de imagen de portada</Label>
<Input
id="coverImage"
value={formData.coverImageUrl}
onChange={(e) => setFormData({ ...formData, coverImageUrl: e.target.value })}
placeholder="https://..."
/>
</div>
<div className="space-y-2">
<Label htmlFor="color">Color</Label>
<Input
id="color"
type="color"
value={formData.color}
onChange={(e) => setFormData({ ...formData, color: e.target.value })}
className="h-10 w-20"
/>
</div>
<div className="flex items-center justify-between">
<Label htmlFor="isPublic">Colección pública</Label>
<Switch
id="isPublic"
checked={formData.isPublic}
onCheckedChange={(checked) => setFormData({ ...formData, isPublic: checked })}
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setIsCreateDialogOpen(false)}>
Cancelar
</Button>
<Button onClick={handleCreate} disabled={!formData.name}>
Crear
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
</div>
{/* Stats Cards */}
{stats && (
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6">
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">
Total Colecciones
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{stats.totalCollections}</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">
Total Items
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{stats.totalItems}</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground flex items-center gap-1">
<Globe className="h-3 w-3" />
Públicas
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{stats.publicCollections}</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground flex items-center gap-1">
<Lock className="h-3 w-3" />
Privadas
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{stats.privateCollections}</div>
</CardContent>
</Card>
</div>
)}
{/* Search */}
<Card className="mb-6">
<CardContent className="pt-6">
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Buscar colecciones..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="pl-10"
/>
</div>
</CardContent>
</Card>
{/* Error State */}
{error && (
<Card className="mb-6 border-destructive">
<CardContent className="pt-6">
<p className="text-destructive">{error}</p>
<Button onClick={handleRefresh} variant="outline" className="mt-2">
Reintentar
</Button>
</CardContent>
</Card>
)}
{/* Empty State */}
{!loading && filteredCollections.length === 0 && (
<Card>
<CardContent className="pt-6">
<div className="text-center py-12">
<FolderHeart className="h-16 w-16 text-muted-foreground mx-auto mb-4" />
<h3 className="text-xl font-semibold mb-2">No hay colecciones</h3>
<p className="text-muted-foreground mb-4">
{searchTerm
? 'No se encontraron colecciones con ese término de búsqueda.'
: 'Crea tu primera colección para organizar lugares.'}
</p>
<Button onClick={() => setIsCreateDialogOpen(true)}>
<Plus className="h-4 w-4 mr-2" />
Crear Colección
</Button>
</div>
</CardContent>
</Card>
)}
{/* Collections Grid */}
{filteredCollections.length > 0 && (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{filteredCollections.map((collection) => (
<Card key={collection.id} className="overflow-hidden">
<div
className="h-32 relative"
style={{
backgroundColor: collection.color || '#3b82f6',
backgroundImage: collection.coverImageUrl
? `url(${collection.coverImageUrl})`
: undefined,
backgroundSize: 'cover',
backgroundPosition: 'center',
}}
>
{!collection.coverImageUrl && (
<div className="absolute inset-0 flex items-center justify-center">
<ImageIcon className="h-12 w-12 text-white/50" />
</div>
)}
<div className="absolute top-2 right-2">
<Badge variant={collection.isPublic ? 'default' : 'secondary'}>
{collection.isPublic ? (
<><Globe className="h-3 w-3 mr-1" /> Pública</>
) : (
<><Lock className="h-3 w-3 mr-1" /> Privada</>
)}
</Badge>
</div>
</div>
<CardHeader>
<div className="flex items-start justify-between">
<div>
<CardTitle className="text-lg">{collection.name}</CardTitle>
<CardDescription className="line-clamp-2">
{collection.description || 'Sin descripción'}
</CardDescription>
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon">
<MoreVertical className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => getCollectionById(collection.id)}>
<Eye className="h-4 w-4 mr-2" />
Ver detalles
</DropdownMenuItem>
<DropdownMenuItem onClick={() => openEditDialog(collection.id)}>
<Edit2 className="h-4 w-4 mr-2" />
Editar
</DropdownMenuItem>
<DropdownMenuItem
className="text-destructive"
onClick={() => openDeleteDialog(collection.id)}
>
<Trash2 className="h-4 w-4 mr-2" />
Eliminar
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</CardHeader>
<CardContent>
<div className="flex items-center justify-between text-sm text-muted-foreground">
<span>{collection.itemCount || 0} items</span>
<span>{new Date(collection.createdAt).toLocaleDateString('es-ES')}</span>
</div>
</CardContent>
</Card>
))}
</div>
)}
{/* Edit Dialog */}
<Dialog open={isEditDialogOpen} onOpenChange={setIsEditDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Editar Colección</DialogTitle>
<DialogDescription>
Modifica los detalles de la colección.
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label htmlFor="edit-name">Nombre</Label>
<Input
id="edit-name"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
/>
</div>
<div className="space-y-2">
<Label htmlFor="edit-description">Descripción</Label>
<Textarea
id="edit-description"
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
/>
</div>
<div className="space-y-2">
<Label htmlFor="edit-coverImage">URL de imagen de portada</Label>
<Input
id="edit-coverImage"
value={formData.coverImageUrl}
onChange={(e) => setFormData({ ...formData, coverImageUrl: e.target.value })}
/>
</div>
<div className="space-y-2">
<Label htmlFor="edit-color">Color</Label>
<Input
id="edit-color"
type="color"
value={formData.color}
onChange={(e) => setFormData({ ...formData, color: e.target.value })}
className="h-10 w-20"
/>
</div>
<div className="flex items-center justify-between">
<Label htmlFor="edit-isPublic">Colección pública</Label>
<Switch
id="edit-isPublic"
checked={formData.isPublic}
onCheckedChange={(checked) => setFormData({ ...formData, isPublic: checked })}
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setIsEditDialogOpen(false)}>
Cancelar
</Button>
<Button onClick={handleEdit} disabled={!formData.name}>
Guardar
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Delete Dialog */}
<AlertDialog open={isDeleteDialogOpen} onOpenChange={setIsDeleteDialogOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>¿Eliminar colección?</AlertDialogTitle>
<AlertDialogDescription>
Esta acción no se puede deshacer. La colección y todos sus items serán eliminados permanentemente.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancelar</AlertDialogCancel>
<AlertDialogAction
onClick={handleDelete}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
Eliminar
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{/* Footer */}
<div className="mt-6 text-center text-sm text-muted-foreground">
Mostrando {filteredCollections.length} de {collections.length} colecciones
</div>
</div>
</div>
);
};
export default Collections;

View File

@@ -0,0 +1,340 @@
import { useState } from 'react';
import { useFavorites } from '@/hooks/useFavorites';
import { FavoriteItemType } from '@/services/favoritesApi';
import {
Heart,
Star,
MapPin,
Trash2,
Filter,
Search,
Building2,
Utensils,
Hotel,
Compass,
ShoppingBag,
RefreshCw,
Eye,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Input } from '@/components/ui/input';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from '@/components/ui/alert-dialog';
const typeIcons: Record<FavoriteItemType, any> = {
place: MapPin,
restaurant: Utensils,
hotel: Hotel,
attraction: Compass,
tour: Compass,
experience: Star,
product: ShoppingBag,
};
const typeLabels: Record<FavoriteItemType, string> = {
place: 'Lugares',
restaurant: 'Restaurantes',
hotel: 'Hoteles',
attraction: 'Atracciones',
tour: 'Tours',
experience: 'Experiencias',
product: 'Productos',
};
const Favorites = () => {
const {
favorites,
counts,
loading,
error,
selectedType,
loadFavorites,
removeFavorite,
changeType,
loadCounts,
} = useFavorites();
const [searchTerm, setSearchTerm] = useState('');
const filteredFavorites = favorites.filter(fav =>
fav.itemName?.toLowerCase().includes(searchTerm.toLowerCase()) ||
fav.notes?.toLowerCase().includes(searchTerm.toLowerCase())
);
const handleRefresh = async () => {
await loadFavorites();
await loadCounts();
};
const handleDelete = async (id: string) => {
await removeFavorite(id);
};
if (loading && favorites.length === 0) {
return (
<div className="body-content">
<div className="container-xxl">
<div className="flex items-center justify-center min-h-96">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary mx-auto"></div>
<p className="mt-4 text-muted-foreground">Cargando favoritos...</p>
</div>
</div>
</div>
</div>
);
}
return (
<div className="body-content">
<div className="container-xxl">
{/* Header */}
<div className="flex flex-col md:flex-row md:items-center md:justify-between mb-6">
<div>
<h1 className="text-2xl font-bold flex items-center gap-2">
<Heart className="h-6 w-6 text-red-500" />
Gestión de Favoritos
</h1>
<p className="text-muted-foreground mt-1">
Administra los favoritos de los usuarios
</p>
</div>
<Button onClick={handleRefresh} variant="outline" className="mt-4 md:mt-0">
<RefreshCw className={`h-4 w-4 mr-2 ${loading ? 'animate-spin' : ''}`} />
Actualizar
</Button>
</div>
{/* Stats Cards */}
{counts && (
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-8 gap-4 mb-6">
<Card className="col-span-2">
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">
Total Favoritos
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{counts.total}</div>
</CardContent>
</Card>
{Object.entries(counts.byType || {}).map(([type, count]) => {
const Icon = typeIcons[type as FavoriteItemType] || Heart;
return (
<Card key={type}>
<CardHeader className="pb-2">
<CardTitle className="text-xs font-medium text-muted-foreground flex items-center gap-1">
<Icon className="h-3 w-3" />
{typeLabels[type as FavoriteItemType] || type}
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-xl font-bold">{count}</div>
</CardContent>
</Card>
);
})}
</div>
)}
{/* Filters */}
<Card className="mb-6">
<CardContent className="pt-6">
<div className="flex flex-col md:flex-row gap-4">
<div className="flex-1">
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Buscar por nombre o notas..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="pl-10"
/>
</div>
</div>
<div className="w-full md:w-48">
<Select
value={selectedType || 'all'}
onValueChange={(value) => changeType(value === 'all' ? undefined : value as FavoriteItemType)}
>
<SelectTrigger>
<Filter className="h-4 w-4 mr-2" />
<SelectValue placeholder="Filtrar por tipo" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Todos los tipos</SelectItem>
{Object.entries(typeLabels).map(([value, label]) => (
<SelectItem key={value} value={value}>
{label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
</CardContent>
</Card>
{/* Error State */}
{error && (
<Card className="mb-6 border-destructive">
<CardContent className="pt-6">
<p className="text-destructive">{error}</p>
<Button onClick={handleRefresh} variant="outline" className="mt-2">
Reintentar
</Button>
</CardContent>
</Card>
)}
{/* Empty State */}
{!loading && filteredFavorites.length === 0 && (
<Card>
<CardContent className="pt-6">
<div className="text-center py-12">
<Heart className="h-16 w-16 text-muted-foreground mx-auto mb-4" />
<h3 className="text-xl font-semibold mb-2">No hay favoritos</h3>
<p className="text-muted-foreground">
{searchTerm
? 'No se encontraron favoritos con ese término de búsqueda.'
: selectedType
? `No hay favoritos de tipo "${typeLabels[selectedType]}".`
: 'Los usuarios aún no han agregado favoritos.'}
</p>
</div>
</CardContent>
</Card>
)}
{/* Favorites Table */}
{filteredFavorites.length > 0 && (
<Card>
<CardContent className="pt-6">
<Table>
<TableHeader>
<TableRow>
<TableHead>Item</TableHead>
<TableHead>Tipo</TableHead>
<TableHead>Notas</TableHead>
<TableHead>Fecha</TableHead>
<TableHead className="text-right">Acciones</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredFavorites.map((favorite) => {
const Icon = typeIcons[favorite.itemType] || Heart;
return (
<TableRow key={favorite.id}>
<TableCell>
<div className="flex items-center gap-3">
{favorite.itemImageUrl ? (
<img
src={favorite.itemImageUrl}
alt={favorite.itemName || 'Favorite'}
className="h-10 w-10 rounded object-cover"
/>
) : (
<div className="h-10 w-10 rounded bg-muted flex items-center justify-center">
<Icon className="h-5 w-5 text-muted-foreground" />
</div>
)}
<div>
<p className="font-medium">{favorite.itemName || 'Sin nombre'}</p>
<p className="text-xs text-muted-foreground">
ID: {favorite.itemId.slice(0, 8)}...
</p>
</div>
</div>
</TableCell>
<TableCell>
<Badge variant="secondary" className="flex items-center gap-1 w-fit">
<Icon className="h-3 w-3" />
{typeLabels[favorite.itemType] || favorite.itemType}
</Badge>
</TableCell>
<TableCell>
<p className="text-sm text-muted-foreground max-w-xs truncate">
{favorite.notes || '-'}
</p>
</TableCell>
<TableCell>
<p className="text-sm text-muted-foreground">
{new Date(favorite.createdAt).toLocaleDateString('es-ES')}
</p>
</TableCell>
<TableCell className="text-right">
<div className="flex justify-end gap-2">
<Button variant="ghost" size="icon">
<Eye className="h-4 w-4" />
</Button>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="ghost" size="icon" className="text-destructive">
<Trash2 className="h-4 w-4" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>¿Eliminar favorito?</AlertDialogTitle>
<AlertDialogDescription>
Esta acción no se puede deshacer. El favorito será eliminado permanentemente.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancelar</AlertDialogCancel>
<AlertDialogAction
onClick={() => handleDelete(favorite.id)}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
Eliminar
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</CardContent>
</Card>
)}
{/* Footer */}
<div className="mt-6 text-center text-sm text-muted-foreground">
Mostrando {filteredFavorites.length} de {favorites.length} favoritos
</div>
</div>
</div>
);
};
export default Favorites;

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,445 @@
import { useState } from 'react';
import { useQuiz } from '@/hooks/useQuiz';
import {
ClipboardList,
RefreshCw,
CheckCircle,
XCircle,
ChevronLeft,
ChevronRight,
Send,
RotateCcw,
User,
Sparkles,
Target,
Award,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
import { Progress } from '@/components/ui/progress';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from '@/components/ui/alert-dialog';
const Quiz = () => {
const {
questions,
quizResponse,
currentQuestion,
currentQuestionIndex,
loading,
submitting,
error,
loadQuestions,
answerQuestion,
nextQuestion,
prevQuestion,
submitQuiz,
resetQuiz,
isCompleted,
getTravelPersona,
getAnswer,
isAnswered,
getProgress,
isFirstQuestion,
isLastQuestion,
} = useQuiz();
const [showQuiz, setShowQuiz] = useState(false);
const progress = getProgress();
const travelPersona = getTravelPersona();
const completed = isCompleted();
const handleRefresh = async () => {
await loadQuestions();
};
const handleStartQuiz = () => {
setShowQuiz(true);
};
const handleSubmit = async () => {
const result = await submitQuiz();
if (result) {
setShowQuiz(false);
}
};
const handleReset = async () => {
const success = await resetQuiz();
if (success) {
setShowQuiz(false);
}
};
if (loading && questions.length === 0) {
return (
<div className="body-content">
<div className="container-xxl">
<div className="flex items-center justify-center min-h-96">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary mx-auto"></div>
<p className="mt-4 text-muted-foreground">Cargando quiz...</p>
</div>
</div>
</div>
</div>
);
}
// Vista del resultado si ya completó el quiz
if (completed && travelPersona && !showQuiz) {
return (
<div className="body-content">
<div className="container-xxl">
{/* Header */}
<div className="flex flex-col md:flex-row md:items-center md:justify-between mb-6">
<div>
<h1 className="text-2xl font-bold flex items-center gap-2">
<ClipboardList className="h-6 w-6 text-primary" />
Quiz de Estilo de Viaje
</h1>
<p className="text-muted-foreground mt-1">
Descubre tu personalidad viajera
</p>
</div>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="outline" className="mt-4 md:mt-0">
<RotateCcw className="h-4 w-4 mr-2" />
Reiniciar Quiz
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>¿Reiniciar quiz?</AlertDialogTitle>
<AlertDialogDescription>
Esto borrará tu Travel Persona actual y podrás volver a tomar el quiz.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancelar</AlertDialogCancel>
<AlertDialogAction onClick={handleReset}>
Reiniciar
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
{/* Result Card */}
<div className="max-w-2xl mx-auto">
<Card className="overflow-hidden">
<div className="h-32 bg-gradient-to-br from-primary to-primary/60 flex items-center justify-center">
<Award className="h-20 w-20 text-white" />
</div>
<CardHeader className="text-center">
<Badge className="w-fit mx-auto mb-2" variant="secondary">
<CheckCircle className="h-3 w-3 mr-1" />
Quiz Completado
</Badge>
<CardTitle className="text-2xl">Tu Travel Persona</CardTitle>
</CardHeader>
<CardContent className="text-center space-y-6">
<div className="py-6">
<div className="inline-flex items-center justify-center w-24 h-24 rounded-full bg-primary/10 mb-4">
<Sparkles className="h-12 w-12 text-primary" />
</div>
<h2 className="text-3xl font-bold text-primary mb-2">
{travelPersona.persona}
</h2>
<p className="text-muted-foreground max-w-md mx-auto">
{travelPersona.description}
</p>
</div>
{quizResponse && (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 text-left">
{quizResponse.travelStyles && quizResponse.travelStyles.length > 0 && (
<div className="p-4 bg-muted rounded-lg">
<h4 className="font-semibold mb-2 flex items-center gap-2">
<Target className="h-4 w-4 text-primary" />
Estilos de Viaje
</h4>
<div className="flex flex-wrap gap-2">
{quizResponse.travelStyles.map((style, i) => (
<Badge key={i} variant="outline">{style}</Badge>
))}
</div>
</div>
)}
{quizResponse.preferredActivities && quizResponse.preferredActivities.length > 0 && (
<div className="p-4 bg-muted rounded-lg">
<h4 className="font-semibold mb-2 flex items-center gap-2">
<Sparkles className="h-4 w-4 text-primary" />
Actividades Preferidas
</h4>
<div className="flex flex-wrap gap-2">
{quizResponse.preferredActivities.slice(0, 5).map((activity, i) => (
<Badge key={i} variant="outline">{activity}</Badge>
))}
</div>
</div>
)}
{quizResponse.budgetRange && (
<div className="p-4 bg-muted rounded-lg">
<h4 className="font-semibold mb-2">Rango de Presupuesto</h4>
<Badge>{quizResponse.budgetRange}</Badge>
</div>
)}
{quizResponse.groupType && (
<div className="p-4 bg-muted rounded-lg">
<h4 className="font-semibold mb-2">Tipo de Grupo</h4>
<Badge>{quizResponse.groupType}</Badge>
</div>
)}
</div>
)}
<p className="text-sm text-muted-foreground">
Completado el {quizResponse?.completedAt
? new Date(quizResponse.completedAt).toLocaleDateString('es-ES')
: '-'}
</p>
</CardContent>
</Card>
</div>
</div>
</div>
);
}
// Vista del quiz
if (showQuiz && currentQuestion) {
return (
<div className="body-content">
<div className="container-xxl">
<div className="max-w-2xl mx-auto">
{/* Progress */}
<div className="mb-6">
<div className="flex items-center justify-between mb-2">
<span className="text-sm text-muted-foreground">
Pregunta {currentQuestionIndex + 1} de {questions.length}
</span>
<span className="text-sm font-medium">
{progress.percentage}% completado
</span>
</div>
<Progress value={progress.percentage} className="h-2" />
</div>
{/* Question Card */}
<Card>
<CardHeader>
<Badge variant="outline" className="w-fit mb-2">
{currentQuestion.category}
</Badge>
<CardTitle className="text-xl">
{currentQuestion.question}
</CardTitle>
{currentQuestion.type === 'multiple' && (
<CardDescription>
Puedes seleccionar múltiples opciones
</CardDescription>
)}
</CardHeader>
<CardContent className="space-y-3">
{currentQuestion.options.map((option) => {
const currentAnswer = getAnswer(currentQuestion.id) || [];
const isSelected = currentAnswer.includes(option.value);
return (
<button
key={option.id}
onClick={() => {
if (currentQuestion.type === 'multiple') {
const newAnswer = isSelected
? currentAnswer.filter(v => v !== option.value)
: [...currentAnswer, option.value];
answerQuestion(currentQuestion.id, newAnswer);
} else {
answerQuestion(currentQuestion.id, [option.value]);
}
}}
className={`w-full text-left p-4 rounded-lg border-2 transition-all ${
isSelected
? 'border-primary bg-primary/5'
: 'border-border hover:border-primary/50'
}`}
>
<div className="flex items-start gap-3">
<div className={`w-5 h-5 rounded-full border-2 flex items-center justify-center flex-shrink-0 mt-0.5 ${
isSelected ? 'border-primary bg-primary' : 'border-muted-foreground'
}`}>
{isSelected && <CheckCircle className="h-3 w-3 text-white" />}
</div>
<div>
<p className="font-medium">{option.label}</p>
{option.description && (
<p className="text-sm text-muted-foreground mt-1">
{option.description}
</p>
)}
</div>
</div>
</button>
);
})}
</CardContent>
</Card>
{/* Navigation */}
<div className="flex items-center justify-between mt-6">
<Button
variant="outline"
onClick={prevQuestion}
disabled={isFirstQuestion}
>
<ChevronLeft className="h-4 w-4 mr-2" />
Anterior
</Button>
{isLastQuestion ? (
<Button
onClick={handleSubmit}
disabled={submitting || progress.answered < questions.length}
>
{submitting ? (
<>
<RefreshCw className="h-4 w-4 mr-2 animate-spin" />
Enviando...
</>
) : (
<>
<Send className="h-4 w-4 mr-2" />
Enviar Quiz
</>
)}
</Button>
) : (
<Button onClick={nextQuestion}>
Siguiente
<ChevronRight className="h-4 w-4 ml-2" />
</Button>
)}
</div>
{/* Question indicators */}
<div className="flex justify-center gap-2 mt-6">
{questions.map((q, index) => (
<button
key={q.id}
onClick={() => {
// goToQuestion not exposed, using prev/next
}}
className={`w-3 h-3 rounded-full transition-all ${
index === currentQuestionIndex
? 'bg-primary scale-125'
: isAnswered(q.id)
? 'bg-primary/50'
: 'bg-muted'
}`}
/>
))}
</div>
</div>
</div>
</div>
);
}
// Vista inicial - Invitación a tomar el quiz
return (
<div className="body-content">
<div className="container-xxl">
{/* Header */}
<div className="flex flex-col md:flex-row md:items-center md:justify-between mb-6">
<div>
<h1 className="text-2xl font-bold flex items-center gap-2">
<ClipboardList className="h-6 w-6 text-primary" />
Quiz de Estilo de Viaje
</h1>
<p className="text-muted-foreground mt-1">
Descubre tu personalidad viajera y recibe recomendaciones personalizadas
</p>
</div>
<Button onClick={handleRefresh} variant="outline" className="mt-4 md:mt-0">
<RefreshCw className={`h-4 w-4 mr-2 ${loading ? 'animate-spin' : ''}`} />
Actualizar
</Button>
</div>
{/* Error State */}
{error && (
<Card className="mb-6 border-destructive">
<CardContent className="pt-6">
<p className="text-destructive">{error}</p>
<Button onClick={handleRefresh} variant="outline" className="mt-2">
Reintentar
</Button>
</CardContent>
</Card>
)}
{/* Welcome Card */}
<div className="max-w-2xl mx-auto">
<Card className="overflow-hidden">
<div className="h-48 bg-gradient-to-br from-primary via-primary/80 to-primary/60 flex items-center justify-center relative">
<div className="absolute inset-0 bg-[url('/src/assets/hero-beach.jpg')] bg-cover bg-center opacity-20" />
<div className="relative text-center text-white p-6">
<User className="h-16 w-16 mx-auto mb-4" />
<h2 className="text-2xl font-bold">Descubre tu Travel Persona</h2>
</div>
</div>
<CardContent className="p-6 space-y-6">
<div className="text-center">
<p className="text-lg text-muted-foreground mb-6">
Responde unas preguntas rápidas sobre tus preferencias de viaje y descubriremos
qué tipo de viajero eres. Esto nos ayudará a darte mejores recomendaciones.
</p>
<div className="grid grid-cols-3 gap-4 mb-6">
<div className="text-center p-4">
<div className="w-12 h-12 rounded-full bg-primary/10 flex items-center justify-center mx-auto mb-2">
<ClipboardList className="h-6 w-6 text-primary" />
</div>
<p className="text-sm font-medium">{questions.length} Preguntas</p>
</div>
<div className="text-center p-4">
<div className="w-12 h-12 rounded-full bg-primary/10 flex items-center justify-center mx-auto mb-2">
<Target className="h-6 w-6 text-primary" />
</div>
<p className="text-sm font-medium">~3 Minutos</p>
</div>
<div className="text-center p-4">
<div className="w-12 h-12 rounded-full bg-primary/10 flex items-center justify-center mx-auto mb-2">
<Sparkles className="h-6 w-6 text-primary" />
</div>
<p className="text-sm font-medium">Personalizado</p>
</div>
</div>
<Button size="lg" onClick={handleStartQuiz} disabled={questions.length === 0}>
<Sparkles className="h-5 w-5 mr-2" />
Comenzar Quiz
</Button>
</div>
</CardContent>
</Card>
</div>
</div>
</div>
);
};
export default Quiz;

View File

@@ -0,0 +1,61 @@
import React, { useState } from 'react';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import RoleManagement from '@/components/roles/RoleManagement';
import { Shield, Building2, Utensils, Store } from 'lucide-react';
const RolesPermissions = () => {
const [activeTab, setActiveTab] = useState('admin');
return (
<div className="min-h-screen bg-background p-6">
<div className="max-w-7xl mx-auto">
<div className="flex items-center gap-3 mb-6">
<Shield className="w-8 h-8 text-primary" />
<div>
<h1 className="text-3xl font-bold">Roles & Permissions</h1>
<p className="text-muted-foreground">Manage access control for all entity types</p>
</div>
</div>
<Tabs value={activeTab} onValueChange={setActiveTab} className="space-y-6">
<TabsList className="grid w-full grid-cols-4 lg:w-auto">
<TabsTrigger value="admin" className="flex items-center gap-2">
<Shield className="w-4 h-4" />
Admin
</TabsTrigger>
<TabsTrigger value="hotel" className="flex items-center gap-2">
<Building2 className="w-4 h-4" />
Hotel
</TabsTrigger>
<TabsTrigger value="restaurant" className="flex items-center gap-2">
<Utensils className="w-4 h-4" />
Restaurant
</TabsTrigger>
<TabsTrigger value="commerce" className="flex items-center gap-2">
<Store className="w-4 h-4" />
Commerce
</TabsTrigger>
</TabsList>
<TabsContent value="admin">
<RoleManagement entityType="admin" />
</TabsContent>
<TabsContent value="hotel">
<RoleManagement entityType="hotel" />
</TabsContent>
<TabsContent value="restaurant">
<RoleManagement entityType="restaurant" />
</TabsContent>
<TabsContent value="commerce">
<RoleManagement entityType="commerce" />
</TabsContent>
</Tabs>
</div>
</div>
);
};
export default RolesPermissions;

View File

@@ -1,5 +1,4 @@
import { useState } from 'react';
import DashboardLayout from '@/components/DashboardLayout';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { SearchBar } from '@/components/shared/SearchBar';
@@ -46,7 +45,6 @@ const SearchEstablishments = () => {
};
return (
<DashboardLayout>
<div className="p-6 space-y-6">
{/* Header */}
<div>
@@ -205,7 +203,6 @@ const SearchEstablishments = () => {
</div>
)}
</div>
</DashboardLayout>
);
};

View File

@@ -1,5 +1,4 @@
import { useState } from 'react';
import DashboardLayout from '@/components/DashboardLayout';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { SearchBar } from '@/components/shared/SearchBar';
@@ -32,7 +31,6 @@ const SearchPlaces = () => {
};
return (
<DashboardLayout>
<div className="p-6 space-y-6">
{/* Header */}
<div>
@@ -158,7 +156,6 @@ const SearchPlaces = () => {
</div>
)}
</div>
</DashboardLayout>
);
};

View File

@@ -0,0 +1,636 @@
import { useState } from 'react';
import { useTrips } from '@/hooks/useTrips';
import { TripStatus } from '@/services/tripsApi';
import {
Plane,
Plus,
Trash2,
Edit2,
Eye,
Calendar,
Users,
DollarSign,
MapPin,
RefreshCw,
Search,
MoreVertical,
Clock,
CheckCircle,
XCircle,
PlayCircle,
PauseCircle,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Input } from '@/components/ui/input';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
const statusConfig: Record<TripStatus, { label: string; icon: any; color: string }> = {
planning: { label: 'Planificando', icon: Clock, color: 'bg-yellow-500' },
upcoming: { label: 'Próximo', icon: Calendar, color: 'bg-blue-500' },
in_progress: { label: 'En progreso', icon: PlayCircle, color: 'bg-green-500' },
completed: { label: 'Completado', icon: CheckCircle, color: 'bg-gray-500' },
cancelled: { label: 'Cancelado', icon: XCircle, color: 'bg-red-500' },
};
const Trips = () => {
const {
trips,
stats,
loading,
error,
selectedStatus,
loadTrips,
createTrip,
updateTrip,
deleteTrip,
getTripById,
selectedTrip,
setSelectedTrip,
changeStatus,
} = useTrips();
const [searchTerm, setSearchTerm] = useState('');
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false);
const [isEditDialogOpen, setIsEditDialogOpen] = useState(false);
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
const [tripToDelete, setTripToDelete] = useState<string | null>(null);
// Form state
const [formData, setFormData] = useState({
name: '',
description: '',
destination: '',
startDate: '',
endDate: '',
travelersCount: 1,
estimatedBudget: 0,
currency: 'USD',
isPublic: false,
});
const filteredTrips = trips.filter(trip =>
trip.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
trip.destination?.toLowerCase().includes(searchTerm.toLowerCase()) ||
trip.description?.toLowerCase().includes(searchTerm.toLowerCase())
);
const handleRefresh = async () => {
await loadTrips();
};
const handleCreate = async () => {
const result = await createTrip(formData);
if (result) {
setIsCreateDialogOpen(false);
resetForm();
}
};
const handleEdit = async () => {
if (!selectedTrip) return;
const success = await updateTrip(selectedTrip.id, formData);
if (success) {
setIsEditDialogOpen(false);
resetForm();
}
};
const handleDelete = async () => {
if (!tripToDelete) return;
const success = await deleteTrip(tripToDelete);
if (success) {
setIsDeleteDialogOpen(false);
setTripToDelete(null);
}
};
const openEditDialog = async (tripId: string) => {
const trip = await getTripById(tripId);
if (trip) {
setFormData({
name: trip.name,
description: trip.description || '',
destination: trip.destination || '',
startDate: trip.startDate || '',
endDate: trip.endDate || '',
travelersCount: trip.travelersCount || 1,
estimatedBudget: trip.estimatedBudget || 0,
currency: trip.currency || 'USD',
isPublic: trip.isPublic,
});
setIsEditDialogOpen(true);
}
};
const openDeleteDialog = (tripId: string) => {
setTripToDelete(tripId);
setIsDeleteDialogOpen(true);
};
const resetForm = () => {
setFormData({
name: '',
description: '',
destination: '',
startDate: '',
endDate: '',
travelersCount: 1,
estimatedBudget: 0,
currency: 'USD',
isPublic: false,
});
setSelectedTrip(null);
};
const formatDate = (date: string | undefined) => {
if (!date) return '-';
return new Date(date).toLocaleDateString('es-ES', {
day: 'numeric',
month: 'short',
year: 'numeric',
});
};
if (loading && trips.length === 0) {
return (
<div className="body-content">
<div className="container-xxl">
<div className="flex items-center justify-center min-h-96">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary mx-auto"></div>
<p className="mt-4 text-muted-foreground">Cargando viajes...</p>
</div>
</div>
</div>
</div>
);
}
return (
<div className="body-content">
<div className="container-xxl">
{/* Header */}
<div className="flex flex-col md:flex-row md:items-center md:justify-between mb-6">
<div>
<h1 className="text-2xl font-bold flex items-center gap-2">
<Plane className="h-6 w-6 text-primary" />
Gestión de Viajes
</h1>
<p className="text-muted-foreground mt-1">
Administra los itinerarios de viaje de los usuarios
</p>
</div>
<div className="flex gap-2 mt-4 md:mt-0">
<Button onClick={handleRefresh} variant="outline">
<RefreshCw className={`h-4 w-4 mr-2 ${loading ? 'animate-spin' : ''}`} />
Actualizar
</Button>
<Dialog open={isCreateDialogOpen} onOpenChange={setIsCreateDialogOpen}>
<DialogTrigger asChild>
<Button>
<Plus className="h-4 w-4 mr-2" />
Nuevo Viaje
</Button>
</DialogTrigger>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>Crear Viaje</DialogTitle>
<DialogDescription>
Crea un nuevo itinerario de viaje.
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4 max-h-96 overflow-y-auto">
<div className="space-y-2">
<Label htmlFor="name">Nombre del viaje</Label>
<Input
id="name"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
placeholder="Mi viaje a..."
/>
</div>
<div className="space-y-2">
<Label htmlFor="destination">Destino</Label>
<Input
id="destination"
value={formData.destination}
onChange={(e) => setFormData({ ...formData, destination: e.target.value })}
placeholder="Punta Cana, RD"
/>
</div>
<div className="space-y-2">
<Label htmlFor="description">Descripción</Label>
<Textarea
id="description"
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
placeholder="Describe tu viaje..."
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="startDate">Fecha inicio</Label>
<Input
id="startDate"
type="date"
value={formData.startDate}
onChange={(e) => setFormData({ ...formData, startDate: e.target.value })}
/>
</div>
<div className="space-y-2">
<Label htmlFor="endDate">Fecha fin</Label>
<Input
id="endDate"
type="date"
value={formData.endDate}
onChange={(e) => setFormData({ ...formData, endDate: e.target.value })}
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="travelers">Viajeros</Label>
<Input
id="travelers"
type="number"
min="1"
value={formData.travelersCount}
onChange={(e) => setFormData({ ...formData, travelersCount: parseInt(e.target.value) || 1 })}
/>
</div>
<div className="space-y-2">
<Label htmlFor="budget">Presupuesto</Label>
<Input
id="budget"
type="number"
min="0"
value={formData.estimatedBudget}
onChange={(e) => setFormData({ ...formData, estimatedBudget: parseFloat(e.target.value) || 0 })}
/>
</div>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setIsCreateDialogOpen(false)}>
Cancelar
</Button>
<Button onClick={handleCreate} disabled={!formData.name}>
Crear
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
</div>
{/* Stats Cards */}
{stats && (
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-4 mb-6">
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">
Total Viajes
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{stats.totalTrips}</div>
</CardContent>
</Card>
{Object.entries(stats.byStatus || {}).map(([status, count]) => {
const config = statusConfig[status as TripStatus];
const Icon = config?.icon || Clock;
return (
<Card key={status}>
<CardHeader className="pb-2">
<CardTitle className="text-xs font-medium text-muted-foreground flex items-center gap-1">
<Icon className="h-3 w-3" />
{config?.label || status}
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-xl font-bold">{count}</div>
</CardContent>
</Card>
);
})}
</div>
)}
{/* Filters */}
<Card className="mb-6">
<CardContent className="pt-6">
<div className="flex flex-col md:flex-row gap-4">
<div className="flex-1">
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Buscar viajes..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="pl-10"
/>
</div>
</div>
<div className="w-full md:w-48">
<Select
value={selectedStatus || 'all'}
onValueChange={(value) => changeStatus(value === 'all' ? undefined : value as TripStatus)}
>
<SelectTrigger>
<SelectValue placeholder="Filtrar por estado" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Todos los estados</SelectItem>
{Object.entries(statusConfig).map(([value, config]) => (
<SelectItem key={value} value={value}>
{config.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
</CardContent>
</Card>
{/* Error State */}
{error && (
<Card className="mb-6 border-destructive">
<CardContent className="pt-6">
<p className="text-destructive">{error}</p>
<Button onClick={handleRefresh} variant="outline" className="mt-2">
Reintentar
</Button>
</CardContent>
</Card>
)}
{/* Empty State */}
{!loading && filteredTrips.length === 0 && (
<Card>
<CardContent className="pt-6">
<div className="text-center py-12">
<Plane className="h-16 w-16 text-muted-foreground mx-auto mb-4" />
<h3 className="text-xl font-semibold mb-2">No hay viajes</h3>
<p className="text-muted-foreground mb-4">
{searchTerm
? 'No se encontraron viajes con ese término de búsqueda.'
: selectedStatus
? `No hay viajes con estado "${statusConfig[selectedStatus]?.label}".`
: 'Crea tu primer itinerario de viaje.'}
</p>
<Button onClick={() => setIsCreateDialogOpen(true)}>
<Plus className="h-4 w-4 mr-2" />
Crear Viaje
</Button>
</div>
</CardContent>
</Card>
)}
{/* Trips Grid */}
{filteredTrips.length > 0 && (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{filteredTrips.map((trip) => {
const statusInfo = statusConfig[trip.status];
const StatusIcon = statusInfo?.icon || Clock;
return (
<Card key={trip.id} className="overflow-hidden">
<div
className="h-32 relative bg-gradient-to-br from-primary/20 to-primary/5"
style={{
backgroundImage: trip.coverImageUrl
? `url(${trip.coverImageUrl})`
: undefined,
backgroundSize: 'cover',
backgroundPosition: 'center',
}}
>
<div className="absolute inset-0 bg-gradient-to-t from-black/60 to-transparent" />
<div className="absolute bottom-3 left-3 right-3">
<h3 className="text-white font-bold text-lg truncate">{trip.name}</h3>
{trip.destination && (
<p className="text-white/80 text-sm flex items-center gap-1">
<MapPin className="h-3 w-3" />
{trip.destination}
</p>
)}
</div>
<div className="absolute top-2 right-2">
<Badge className={`${statusInfo?.color} text-white`}>
<StatusIcon className="h-3 w-3 mr-1" />
{statusInfo?.label}
</Badge>
</div>
</div>
<CardContent className="pt-4">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-4 text-sm text-muted-foreground">
<span className="flex items-center gap-1">
<Calendar className="h-4 w-4" />
{formatDate(trip.startDate)}
</span>
<span className="flex items-center gap-1">
<Users className="h-4 w-4" />
{trip.travelersCount}
</span>
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon">
<MoreVertical className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => getTripById(trip.id)}>
<Eye className="h-4 w-4 mr-2" />
Ver detalles
</DropdownMenuItem>
<DropdownMenuItem onClick={() => openEditDialog(trip.id)}>
<Edit2 className="h-4 w-4 mr-2" />
Editar
</DropdownMenuItem>
<DropdownMenuItem
className="text-destructive"
onClick={() => openDeleteDialog(trip.id)}
>
<Trash2 className="h-4 w-4 mr-2" />
Eliminar
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
{trip.estimatedBudget && trip.estimatedBudget > 0 && (
<div className="flex items-center gap-1 text-sm">
<DollarSign className="h-4 w-4 text-green-500" />
<span className="font-medium">
{trip.estimatedBudget.toLocaleString('es-ES')} {trip.currency}
</span>
</div>
)}
{trip.days && trip.days.length > 0 && (
<p className="text-sm text-muted-foreground mt-2">
{trip.days.length} días planificados
</p>
)}
</CardContent>
</Card>
);
})}
</div>
)}
{/* Edit Dialog */}
<Dialog open={isEditDialogOpen} onOpenChange={setIsEditDialogOpen}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>Editar Viaje</DialogTitle>
<DialogDescription>
Modifica los detalles del viaje.
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4 max-h-96 overflow-y-auto">
<div className="space-y-2">
<Label htmlFor="edit-name">Nombre del viaje</Label>
<Input
id="edit-name"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
/>
</div>
<div className="space-y-2">
<Label htmlFor="edit-destination">Destino</Label>
<Input
id="edit-destination"
value={formData.destination}
onChange={(e) => setFormData({ ...formData, destination: e.target.value })}
/>
</div>
<div className="space-y-2">
<Label htmlFor="edit-description">Descripción</Label>
<Textarea
id="edit-description"
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="edit-startDate">Fecha inicio</Label>
<Input
id="edit-startDate"
type="date"
value={formData.startDate}
onChange={(e) => setFormData({ ...formData, startDate: e.target.value })}
/>
</div>
<div className="space-y-2">
<Label htmlFor="edit-endDate">Fecha fin</Label>
<Input
id="edit-endDate"
type="date"
value={formData.endDate}
onChange={(e) => setFormData({ ...formData, endDate: e.target.value })}
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="edit-travelers">Viajeros</Label>
<Input
id="edit-travelers"
type="number"
min="1"
value={formData.travelersCount}
onChange={(e) => setFormData({ ...formData, travelersCount: parseInt(e.target.value) || 1 })}
/>
</div>
<div className="space-y-2">
<Label htmlFor="edit-budget">Presupuesto</Label>
<Input
id="edit-budget"
type="number"
min="0"
value={formData.estimatedBudget}
onChange={(e) => setFormData({ ...formData, estimatedBudget: parseFloat(e.target.value) || 0 })}
/>
</div>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setIsEditDialogOpen(false)}>
Cancelar
</Button>
<Button onClick={handleEdit} disabled={!formData.name}>
Guardar
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Delete Dialog */}
<AlertDialog open={isDeleteDialogOpen} onOpenChange={setIsDeleteDialogOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>¿Eliminar viaje?</AlertDialogTitle>
<AlertDialogDescription>
Esta acción no se puede deshacer. El viaje y todos sus días y actividades serán eliminados permanentemente.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancelar</AlertDialogCancel>
<AlertDialogAction
onClick={handleDelete}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
Eliminar
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{/* Footer */}
<div className="mt-6 text-center text-sm text-muted-foreground">
Mostrando {filteredTrips.length} de {trips.length} viajes
</div>
</div>
</div>
);
};
export default Trips;

View File

@@ -20,6 +20,7 @@ import {
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import {
Mail,
Plus,
@@ -32,10 +33,12 @@ import {
Eye,
MousePointer,
ShoppingCart,
Calendar
Calendar,
Megaphone
} from 'lucide-react';
import { useToast } from '@/hooks/use-toast';
import { z } from 'zod';
import InfluencerMarketplace from '@/components/InfluencerMarketplace';
const campaignSchema = z.object({
name: z.string().trim().min(1, 'Nombre requerido').max(100, 'Nombre muy largo'),
@@ -152,9 +155,28 @@ const Campaigns = () => {
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold text-gray-900">Campañas</h1>
<p className="text-gray-600 mt-1">Gestiona tus campañas de marketing</p>
<h1 className="text-3xl font-bold text-gray-900">Campañas de Marketing</h1>
<p className="text-gray-600 mt-1">Gestiona tus campañas de email e influencer marketing</p>
</div>
</div>
{/* Tabs */}
<Tabs defaultValue="email" className="space-y-6">
<TabsList className="grid w-full max-w-md grid-cols-2">
<TabsTrigger value="email" className="flex items-center gap-2">
<Mail className="h-4 w-4" />
Email Marketing
</TabsTrigger>
<TabsTrigger value="influencers" className="flex items-center gap-2">
<Users className="h-4 w-4" />
Influencers
</TabsTrigger>
</TabsList>
{/* Email Marketing Tab */}
<TabsContent value="email" className="space-y-6">
{/* Create Button */}
<div className="flex justify-end">
<Dialog open={isCreateDialogOpen} onOpenChange={setIsCreateDialogOpen}>
<DialogTrigger asChild>
<Button>
@@ -412,6 +434,13 @@ const Campaigns = () => {
</div>
</CardContent>
</Card>
</TabsContent>
{/* Influencers Tab */}
<TabsContent value="influencers">
<InfluencerMarketplace />
</TabsContent>
</Tabs>
</div>
);
};

View File

@@ -0,0 +1,562 @@
import React, { useState, useEffect } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Progress } from '@/components/ui/progress';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { Label } from '@/components/ui/label';
import {
Users,
TrendingUp,
DollarSign,
Eye,
Heart,
MessageCircle,
Instagram,
Youtube,
Twitter,
Star,
Calendar,
ArrowUpRight,
ArrowDownRight,
CheckCircle,
Clock,
Megaphone,
BarChart3,
Globe,
Loader2,
Home,
User,
Wallet,
MapPin,
Link as LinkIcon,
Camera,
Edit,
Download,
Filter,
Search,
CreditCard,
BanknoteIcon,
PieChart
} from 'lucide-react';
const InfluencerDashboard = () => {
const location = useLocation();
const navigate = useNavigate();
const [loading, setLoading] = useState(true);
// Determinar tab activo basado en la ruta
const getActiveTab = () => {
if (location.pathname.includes('/stats')) return 'stats';
if (location.pathname.includes('/campaigns')) return 'campaigns';
if (location.pathname.includes('/earnings')) return 'earnings';
if (location.pathname.includes('/profile')) return 'profile';
return 'dashboard';
};
const [activeTab, setActiveTab] = useState(getActiveTab());
useEffect(() => {
setActiveTab(getActiveTab());
}, [location.pathname]);
const handleTabChange = (value: string) => {
setActiveTab(value);
const routes: Record<string, string> = {
dashboard: '/dashboard/influencer',
stats: '/dashboard/influencer/stats',
campaigns: '/dashboard/influencer/campaigns',
earnings: '/dashboard/influencer/earnings',
profile: '/dashboard/influencer/profile'
};
navigate(routes[value]);
};
const [stats] = useState({
followers: 125000,
engagement: 4.8,
totalEarnings: 15750,
pendingPayments: 2500,
activeCampaigns: 3,
completedCampaigns: 45,
avgRating: 4.9,
totalViews: 2500000
});
const [campaigns] = useState([
{ id: 1, brand: 'Resort Paradise', type: 'Hotel Review', status: 'active', payment: 800, deadline: '2024-03-25', progress: 60, description: 'Review del resort con contenido en Instagram y YouTube' },
{ id: 2, brand: 'Caribbean Tours', type: 'Tour Promotion', status: 'pending', payment: 500, deadline: '2024-03-30', progress: 0, description: 'Promocion de tours por el Caribe' },
{ id: 3, brand: 'Local Restaurant', type: 'Food Review', status: 'completed', payment: 350, deadline: '2024-03-10', progress: 100, description: 'Review de restaurante local' },
{ id: 4, brand: 'Beach Hotel', type: 'Hotel Stay', status: 'available', payment: 650, deadline: '2024-04-15', progress: 0, description: 'Estadia de 3 noches con contenido' },
{ id: 5, brand: 'Adventure Co', type: 'Activity Review', status: 'available', payment: 400, deadline: '2024-04-20', progress: 0, description: 'Review de actividades de aventura' }
]);
const [platformStats] = useState([
{ platform: 'Instagram', followers: 85000, engagement: 5.2, posts: 342, icon: Instagram, growth: 3.2 },
{ platform: 'YouTube', followers: 32000, engagement: 4.1, posts: 87, icon: Youtube, growth: 5.1 },
{ platform: 'Twitter', followers: 8000, engagement: 3.8, posts: 1205, icon: Twitter, growth: 1.8 }
]);
const [earnings] = useState([
{ id: 1, brand: 'Resort Paradise', amount: 800, status: 'paid', date: '2024-03-15', method: 'PayPal' },
{ id: 2, brand: 'Caribbean Tours', amount: 500, status: 'pending', date: '2024-03-20', method: 'Bank Transfer' },
{ id: 3, brand: 'Local Restaurant', amount: 350, status: 'paid', date: '2024-03-10', method: 'PayPal' },
{ id: 4, brand: 'Hotel XYZ', amount: 1200, status: 'paid', date: '2024-02-28', method: 'Bank Transfer' },
{ id: 5, brand: 'Tour Company', amount: 450, status: 'processing', date: '2024-03-18', method: 'PayPal' }
]);
const [profileData] = useState({
displayName: 'Maria Rodriguez',
username: 'maria_viajera',
bio: 'Exploradora del Caribe. Comparto los mejores destinos y experiencias de viaje.',
location: 'Santo Domingo, RD',
website: 'https://mariaviajera.com',
categories: ['travel', 'lifestyle', 'food'],
pricePerPost: 500
});
useEffect(() => {
const timer = setTimeout(() => setLoading(false), 300);
return () => clearTimeout(timer);
}, []);
const formatNumber = (num: number) => {
if (num >= 1000000) return `${(num / 1000000).toFixed(1)}M`;
if (num >= 1000) return `${(num / 1000).toFixed(0)}K`;
return num.toString();
};
const getStatusBadge = (status: string) => {
const config: Record<string, { label: string; color: string }> = {
active: { label: 'Activa', color: 'bg-green-100 text-green-700' },
pending: { label: 'Pendiente', color: 'bg-yellow-100 text-yellow-700' },
completed: { label: 'Completada', color: 'bg-gray-100 text-gray-700' },
available: { label: 'Disponible', color: 'bg-blue-100 text-blue-700' },
paid: { label: 'Pagado', color: 'bg-green-100 text-green-700' },
processing: { label: 'Procesando', color: 'bg-orange-100 text-orange-700' }
};
return config[status] || config.pending;
};
if (loading) {
return (
<div className="flex items-center justify-center h-96">
<Loader2 className="w-8 h-8 animate-spin text-orange-500" />
</div>
);
}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold text-gray-900">Influencer Dashboard</h1>
<p className="text-gray-600 mt-1">Gestiona tu perfil, campanas y ganancias</p>
</div>
</div>
{/* Tabs Navigation */}
<Tabs value={activeTab} onValueChange={handleTabChange} className="space-y-6">
<TabsList className="grid w-full max-w-2xl grid-cols-5">
<TabsTrigger value="dashboard" className="flex items-center gap-2">
<Home className="h-4 w-4" />
<span className="hidden sm:inline">Dashboard</span>
</TabsTrigger>
<TabsTrigger value="stats" className="flex items-center gap-2">
<BarChart3 className="h-4 w-4" />
<span className="hidden sm:inline">Estadisticas</span>
</TabsTrigger>
<TabsTrigger value="campaigns" className="flex items-center gap-2">
<Megaphone className="h-4 w-4" />
<span className="hidden sm:inline">Campanas</span>
</TabsTrigger>
<TabsTrigger value="earnings" className="flex items-center gap-2">
<Wallet className="h-4 w-4" />
<span className="hidden sm:inline">Earnings</span>
</TabsTrigger>
<TabsTrigger value="profile" className="flex items-center gap-2">
<User className="h-4 w-4" />
<span className="hidden sm:inline">Perfil</span>
</TabsTrigger>
</TabsList>
{/* Dashboard Tab */}
<TabsContent value="dashboard" className="space-y-6">
{/* Stats Overview */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
<Card>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium text-gray-600">Seguidores</CardTitle>
<Users className="h-5 w-5 text-blue-500" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-gray-900">{formatNumber(stats.followers)}</div>
<p className="text-xs text-green-600 flex items-center mt-1">
<ArrowUpRight className="h-3 w-3 mr-1" />+2.5% este mes
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium text-gray-600">Engagement</CardTitle>
<Heart className="h-5 w-5 text-red-500" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-gray-900">{stats.engagement}%</div>
<p className="text-xs text-green-600 flex items-center mt-1">
<ArrowUpRight className="h-3 w-3 mr-1" />+0.3% vs promedio
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium text-gray-600">Ganancias</CardTitle>
<DollarSign className="h-5 w-5 text-green-500" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-gray-900">${stats.totalEarnings.toLocaleString()}</div>
<p className="text-xs text-gray-500 mt-1">${stats.pendingPayments.toLocaleString()} pendiente</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium text-gray-600">Rating</CardTitle>
<Star className="h-5 w-5 text-yellow-500" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-gray-900 flex items-center">
{stats.avgRating}
<Star className="h-4 w-4 fill-yellow-400 text-yellow-400 ml-1" />
</div>
<p className="text-xs text-gray-500 mt-1">De {stats.completedCampaigns} campanas</p>
</CardContent>
</Card>
</div>
{/* Quick Stats */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<Card className="lg:col-span-1">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Globe className="h-5 w-5 text-orange-500" />
Plataformas
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{platformStats.map((platform) => {
const Icon = platform.icon;
return (
<div key={platform.platform} className="flex items-center gap-3 p-2 bg-gray-50 rounded-lg">
<Icon className="h-5 w-5 text-gray-700" />
<div className="flex-1">
<span className="font-medium text-gray-900">{platform.platform}</span>
</div>
<span className="text-sm text-gray-500">{formatNumber(platform.followers)}</span>
</div>
);
})}
</CardContent>
</Card>
<Card className="lg:col-span-2">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Megaphone className="h-5 w-5 text-orange-500" />
Campanas Activas
</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-3">
{campaigns.filter(c => c.status === 'active').map((campaign) => (
<div key={campaign.id} className="flex items-center justify-between p-3 border rounded-lg">
<div>
<h4 className="font-semibold text-gray-900">{campaign.brand}</h4>
<p className="text-sm text-gray-500">{campaign.type}</p>
</div>
<div className="flex items-center gap-3">
<Progress value={campaign.progress} className="w-20 h-2" />
<span className="text-sm text-gray-500">{campaign.progress}%</span>
</div>
</div>
))}
</div>
</CardContent>
</Card>
</div>
</TabsContent>
{/* Stats Tab */}
<TabsContent value="stats" className="space-y-6">
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
{platformStats.map((platform) => {
const Icon = platform.icon;
return (
<Card key={platform.platform}>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Icon className="h-5 w-5" />
{platform.platform}
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<p className="text-sm text-gray-500">Seguidores</p>
<p className="text-xl font-bold">{formatNumber(platform.followers)}</p>
</div>
<div>
<p className="text-sm text-gray-500">Engagement</p>
<p className="text-xl font-bold">{platform.engagement}%</p>
</div>
<div>
<p className="text-sm text-gray-500">Posts</p>
<p className="text-xl font-bold">{platform.posts}</p>
</div>
<div>
<p className="text-sm text-gray-500">Crecimiento</p>
<p className="text-xl font-bold text-green-600">+{platform.growth}%</p>
</div>
</div>
</CardContent>
</Card>
);
})}
</div>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<PieChart className="h-5 w-5 text-orange-500" />
Resumen de Rendimiento
</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 md:grid-cols-4 gap-6">
<div className="text-center p-4 bg-gray-50 rounded-lg">
<p className="text-3xl font-bold text-gray-900">{formatNumber(stats.totalViews)}</p>
<p className="text-sm text-gray-500">Vistas Totales</p>
</div>
<div className="text-center p-4 bg-gray-50 rounded-lg">
<p className="text-3xl font-bold text-gray-900">{stats.completedCampaigns}</p>
<p className="text-sm text-gray-500">Campanas Completadas</p>
</div>
<div className="text-center p-4 bg-gray-50 rounded-lg">
<p className="text-3xl font-bold text-gray-900">{stats.avgRating}</p>
<p className="text-sm text-gray-500">Rating Promedio</p>
</div>
<div className="text-center p-4 bg-gray-50 rounded-lg">
<p className="text-3xl font-bold text-gray-900">{stats.engagement}%</p>
<p className="text-sm text-gray-500">Engagement Global</p>
</div>
</div>
</CardContent>
</Card>
</TabsContent>
{/* Campaigns Tab */}
<TabsContent value="campaigns" className="space-y-6">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Input placeholder="Buscar campanas..." className="w-64" />
<Button variant="outline" size="icon"><Filter className="h-4 w-4" /></Button>
</div>
<Button className="bg-gradient-to-r from-orange-500 to-red-500">
<Search className="h-4 w-4 mr-2" />
Explorar Campanas
</Button>
</div>
<div className="grid gap-4">
{campaigns.map((campaign) => {
const statusConfig = getStatusBadge(campaign.status);
return (
<Card key={campaign.id}>
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div className="flex-1">
<div className="flex items-center gap-3">
<h3 className="font-semibold text-gray-900">{campaign.brand}</h3>
<span className={`px-2 py-1 rounded-full text-xs font-medium ${statusConfig.color}`}>
{statusConfig.label}
</span>
</div>
<p className="text-sm text-gray-500 mt-1">{campaign.description}</p>
<div className="flex items-center gap-4 mt-2 text-sm">
<span className="flex items-center gap-1 text-green-600 font-medium">
<DollarSign className="h-4 w-4" />${campaign.payment}
</span>
<span className="flex items-center gap-1 text-gray-500">
<Calendar className="h-4 w-4" />{campaign.deadline}
</span>
</div>
</div>
{campaign.status === 'active' && (
<div className="flex items-center gap-3">
<div className="text-right">
<p className="text-sm text-gray-500">Progreso</p>
<p className="font-bold">{campaign.progress}%</p>
</div>
<Progress value={campaign.progress} className="w-24 h-2" />
</div>
)}
{campaign.status === 'available' && (
<Button size="sm">Aplicar</Button>
)}
</div>
</CardContent>
</Card>
);
})}
</div>
</TabsContent>
{/* Earnings Tab */}
<TabsContent value="earnings" className="space-y-6">
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<Card>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium text-gray-600">Total Ganado</CardTitle>
<DollarSign className="h-5 w-5 text-green-500" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-gray-900">${stats.totalEarnings.toLocaleString()}</div>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium text-gray-600">Pendiente</CardTitle>
<Clock className="h-5 w-5 text-yellow-500" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-yellow-600">${stats.pendingPayments.toLocaleString()}</div>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium text-gray-600">Este Mes</CardTitle>
<TrendingUp className="h-5 w-5 text-blue-500" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-gray-900">$1,650</div>
</CardContent>
</Card>
</div>
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle>Historial de Pagos</CardTitle>
<Button variant="outline" size="sm">
<Download className="h-4 w-4 mr-2" />
Exportar
</Button>
</div>
</CardHeader>
<CardContent>
<div className="space-y-3">
{earnings.map((earning) => {
const statusConfig = getStatusBadge(earning.status);
return (
<div key={earning.id} className="flex items-center justify-between p-3 border rounded-lg">
<div className="flex items-center gap-3">
<div className="p-2 bg-gray-100 rounded-lg">
{earning.method === 'PayPal' ? <CreditCard className="h-5 w-5" /> : <BanknoteIcon className="h-5 w-5" />}
</div>
<div>
<p className="font-medium text-gray-900">{earning.brand}</p>
<p className="text-sm text-gray-500">{earning.date} - {earning.method}</p>
</div>
</div>
<div className="flex items-center gap-3">
<span className={`px-2 py-1 rounded-full text-xs font-medium ${statusConfig.color}`}>
{statusConfig.label}
</span>
<span className="font-bold text-gray-900">${earning.amount}</span>
</div>
</div>
);
})}
</div>
</CardContent>
</Card>
</TabsContent>
{/* Profile Tab */}
<TabsContent value="profile" className="space-y-6">
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle>Mi Perfil Publico</CardTitle>
<Button variant="outline">
<Edit className="h-4 w-4 mr-2" />
Editar Perfil
</Button>
</div>
</CardHeader>
<CardContent className="space-y-6">
<div className="flex items-start gap-6">
<div className="relative">
<div className="w-24 h-24 bg-gradient-to-br from-orange-400 to-red-500 rounded-full flex items-center justify-center">
<span className="text-white text-3xl font-bold">{profileData.displayName[0]}</span>
</div>
<button className="absolute bottom-0 right-0 p-2 bg-white rounded-full shadow-lg">
<Camera className="h-4 w-4 text-gray-600" />
</button>
</div>
<div className="flex-1">
<h2 className="text-2xl font-bold text-gray-900">{profileData.displayName}</h2>
<p className="text-gray-500">@{profileData.username}</p>
<p className="text-gray-600 mt-2">{profileData.bio}</p>
<div className="flex items-center gap-4 mt-3 text-sm text-gray-500">
<span className="flex items-center gap-1">
<MapPin className="h-4 w-4" />{profileData.location}
</span>
<span className="flex items-center gap-1">
<LinkIcon className="h-4 w-4" />{profileData.website}
</span>
</div>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 pt-6 border-t">
<div>
<Label>Nombre</Label>
<Input value={profileData.displayName} className="mt-1" />
</div>
<div>
<Label>Username</Label>
<Input value={profileData.username} className="mt-1" />
</div>
<div className="md:col-span-2">
<Label>Bio</Label>
<Textarea value={profileData.bio} className="mt-1" rows={3} />
</div>
<div>
<Label>Ubicacion</Label>
<Input value={profileData.location} className="mt-1" />
</div>
<div>
<Label>Precio por Post ($)</Label>
<Input type="number" value={profileData.pricePerPost} className="mt-1" />
</div>
</div>
<div className="flex justify-end gap-3 pt-4">
<Button variant="outline">Cancelar</Button>
<Button className="bg-gradient-to-r from-orange-500 to-red-500">
Guardar Cambios
</Button>
</div>
</CardContent>
</Card>
</TabsContent>
</Tabs>
</div>
);
};
export default InfluencerDashboard;

View File

@@ -1,5 +1,5 @@
// API Configuration
const API_BASE_URL = 'https://karibeo.lesoluciones.net:8443/api/v1';
const API_BASE_URL = 'https://api.karibeo.ai:8443/api/v1';
// Get auth token from localStorage (support both keys)
const getAuthToken = () => {

View File

@@ -3,7 +3,7 @@
* Integración con la API del sistema de aplicaciones turísticas
*/
const API_BASE_URL = 'https://karibeo.lesoluciones.net:8443/api/v1';
const API_BASE_URL = 'https://api.karibeo.ai:8443/api/v1';
// Tipos de datos principales
export interface User {

View File

@@ -8,6 +8,7 @@ export interface User {
avatar: string;
online: boolean;
lastSeen?: string;
serviceType?: 'taxi' | 'restaurant' | 'hotel' | 'guide' | 'politur' | 'support';
}
export interface Message {
@@ -32,6 +33,7 @@ export interface Chat {
avatar?: string;
online?: boolean;
lastActivity: string;
serviceType?: 'taxi' | 'restaurant' | 'hotel' | 'guide' | 'politur' | 'support';
}
class ChatApiService {

View File

@@ -0,0 +1,279 @@
/**
* Collections API Service
* Conectado a la API real de Karibeo - Fase 1
*/
import { API_CONFIG } from '@/config/api';
const API_BASE_URL = API_CONFIG.BASE_URL;
export interface CollectionItem {
id: string;
collectionId: string;
itemId: string;
itemType: string;
itemName?: string;
itemImageUrl?: string;
notes?: string;
sortOrder: number;
createdAt: string;
}
export interface Collection {
id: string;
userId: string;
name: string;
description?: string;
coverImageUrl?: string;
color?: string;
icon?: string;
isPublic: boolean;
sortOrder: number;
itemCount?: number;
items?: CollectionItem[];
createdAt: string;
updatedAt: string;
}
export interface CollectionStats {
totalCollections: number;
totalItems: number;
publicCollections: number;
privateCollections: number;
}
export interface CreateCollectionDto {
name: string;
description?: string;
coverImageUrl?: string;
color?: string;
icon?: string;
isPublic?: boolean;
}
export interface UpdateCollectionDto {
name?: string;
description?: string;
coverImageUrl?: string;
color?: string;
icon?: string;
isPublic?: boolean;
}
export interface AddCollectionItemDto {
itemId: string;
itemType: string;
itemName?: string;
itemImageUrl?: string;
notes?: string;
}
class CollectionsApiService {
private baseUrl: string;
constructor() {
this.baseUrl = API_BASE_URL;
}
private getToken(): string | null {
return localStorage.getItem('karibeo_token');
}
private getHeaders(): HeadersInit {
const token = this.getToken();
return {
'Content-Type': 'application/json',
...(token && { Authorization: `Bearer ${token}` }),
};
}
// Obtener mis colecciones
async getMyCollections(): Promise<Collection[]> {
try {
const response = await fetch(`${this.baseUrl}/collections/my`, {
method: 'GET',
headers: this.getHeaders(),
});
if (!response.ok) {
throw new Error(`Error fetching collections: ${response.status}`);
}
return await response.json();
} catch (error) {
console.error('Error fetching collections:', error);
throw error;
}
}
// Obtener estadísticas de colecciones
async getCollectionsStats(): Promise<CollectionStats> {
try {
const response = await fetch(`${this.baseUrl}/collections/my/stats`, {
method: 'GET',
headers: this.getHeaders(),
});
if (!response.ok) {
throw new Error(`Error fetching collections stats: ${response.status}`);
}
return await response.json();
} catch (error) {
console.error('Error fetching collections stats:', error);
throw error;
}
}
// Crear colección
async createCollection(data: CreateCollectionDto): Promise<Collection> {
try {
const response = await fetch(`${this.baseUrl}/collections`, {
method: 'POST',
headers: this.getHeaders(),
body: JSON.stringify(data),
});
if (!response.ok) {
throw new Error(`Error creating collection: ${response.status}`);
}
return await response.json();
} catch (error) {
console.error('Error creating collection:', error);
throw error;
}
}
// Obtener colección por ID
async getCollectionById(id: string): Promise<Collection> {
try {
const response = await fetch(`${this.baseUrl}/collections/${id}`, {
method: 'GET',
headers: this.getHeaders(),
});
if (!response.ok) {
throw new Error(`Error fetching collection: ${response.status}`);
}
return await response.json();
} catch (error) {
console.error('Error fetching collection:', error);
throw error;
}
}
// Actualizar colección
async updateCollection(id: string, data: UpdateCollectionDto): Promise<Collection> {
try {
const response = await fetch(`${this.baseUrl}/collections/${id}`, {
method: 'PATCH',
headers: this.getHeaders(),
body: JSON.stringify(data),
});
if (!response.ok) {
throw new Error(`Error updating collection: ${response.status}`);
}
return await response.json();
} catch (error) {
console.error('Error updating collection:', error);
throw error;
}
}
// Eliminar colección
async deleteCollection(id: string): Promise<void> {
try {
const response = await fetch(`${this.baseUrl}/collections/${id}`, {
method: 'DELETE',
headers: this.getHeaders(),
});
if (!response.ok) {
throw new Error(`Error deleting collection: ${response.status}`);
}
} catch (error) {
console.error('Error deleting collection:', error);
throw error;
}
}
// Agregar item a colección
async addItemToCollection(collectionId: string, data: AddCollectionItemDto): Promise<CollectionItem> {
try {
const response = await fetch(`${this.baseUrl}/collections/${collectionId}/items`, {
method: 'POST',
headers: this.getHeaders(),
body: JSON.stringify(data),
});
if (!response.ok) {
throw new Error(`Error adding item to collection: ${response.status}`);
}
return await response.json();
} catch (error) {
console.error('Error adding item to collection:', error);
throw error;
}
}
// Quitar item de colección
async removeItemFromCollection(collectionId: string, itemId: string): Promise<void> {
try {
const response = await fetch(`${this.baseUrl}/collections/${collectionId}/items/${itemId}`, {
method: 'DELETE',
headers: this.getHeaders(),
});
if (!response.ok) {
throw new Error(`Error removing item from collection: ${response.status}`);
}
} catch (error) {
console.error('Error removing item from collection:', error);
throw error;
}
}
// Reordenar items en colección
async reorderCollectionItems(collectionId: string, itemIds: string[]): Promise<void> {
try {
const response = await fetch(`${this.baseUrl}/collections/${collectionId}/items/order`, {
method: 'PATCH',
headers: this.getHeaders(),
body: JSON.stringify({ itemIds }),
});
if (!response.ok) {
throw new Error(`Error reordering collection items: ${response.status}`);
}
} catch (error) {
console.error('Error reordering collection items:', error);
throw error;
}
}
// Reordenar colecciones
async reorderCollections(collectionIds: string[]): Promise<void> {
try {
const response = await fetch(`${this.baseUrl}/collections/order`, {
method: 'PATCH',
headers: this.getHeaders(),
body: JSON.stringify({ collectionIds }),
});
if (!response.ok) {
throw new Error(`Error reordering collections: ${response.status}`);
}
} catch (error) {
console.error('Error reordering collections:', error);
throw error;
}
}
}
export const collectionsApi = new CollectionsApiService();
export default collectionsApi;

View File

@@ -1 +1 @@
export const API_BASE_URL = 'https://karibeo.lesoluciones.net:8443/api/v1';
export const API_BASE_URL = 'https://api.karibeo.ai:8443/api/v1';

View File

@@ -0,0 +1,241 @@
/**
* Favorites API Service
* Conectado a la API real de Karibeo - Fase 1
*/
import { API_CONFIG } from '@/config/api';
const API_BASE_URL = API_CONFIG.BASE_URL;
// Tipos de items que pueden ser favoritos
export type FavoriteItemType = 'place' | 'restaurant' | 'hotel' | 'attraction' | 'tour' | 'experience' | 'product';
export interface Favorite {
id: string;
userId: string;
itemId: string;
itemType: FavoriteItemType;
itemName?: string;
itemImageUrl?: string;
itemMetadata?: Record<string, any>;
notes?: string;
createdAt: string;
}
export interface FavoritesCounts {
total: number;
byType: Record<FavoriteItemType, number>;
}
export interface CreateFavoriteDto {
itemId: string;
itemType: FavoriteItemType;
itemName?: string;
itemImageUrl?: string;
itemMetadata?: Record<string, any>;
notes?: string;
}
export interface UpdateFavoriteDto {
notes?: string;
itemName?: string;
itemImageUrl?: string;
}
class FavoritesApiService {
private baseUrl: string;
constructor() {
this.baseUrl = API_BASE_URL;
}
private getToken(): string | null {
return localStorage.getItem('karibeo_token');
}
private getHeaders(): HeadersInit {
const token = this.getToken();
return {
'Content-Type': 'application/json',
...(token && { Authorization: `Bearer ${token}` }),
};
}
// Obtener mis favoritos
async getMyFavorites(itemType?: FavoriteItemType): Promise<Favorite[]> {
try {
const url = new URL(`${this.baseUrl}/favorites/my`);
if (itemType) {
url.searchParams.append('itemType', itemType);
}
const response = await fetch(url.toString(), {
method: 'GET',
headers: this.getHeaders(),
});
if (!response.ok) {
throw new Error(`Error fetching favorites: ${response.status}`);
}
return await response.json();
} catch (error) {
console.error('Error fetching favorites:', error);
throw error;
}
}
// Obtener conteo de favoritos por tipo
async getFavoritesCounts(): Promise<FavoritesCounts> {
try {
const response = await fetch(`${this.baseUrl}/favorites/my/counts`, {
method: 'GET',
headers: this.getHeaders(),
});
if (!response.ok) {
throw new Error(`Error fetching favorites counts: ${response.status}`);
}
return await response.json();
} catch (error) {
console.error('Error fetching favorites counts:', error);
throw error;
}
}
// Verificar si un item es favorito
async checkFavorite(itemType: FavoriteItemType, itemId: string): Promise<{ isFavorite: boolean; favoriteId?: string }> {
try {
const response = await fetch(`${this.baseUrl}/favorites/check/${itemType}/${itemId}`, {
method: 'GET',
headers: this.getHeaders(),
});
if (!response.ok) {
throw new Error(`Error checking favorite: ${response.status}`);
}
return await response.json();
} catch (error) {
console.error('Error checking favorite:', error);
throw error;
}
}
// Agregar a favoritos
async addFavorite(data: CreateFavoriteDto): Promise<Favorite> {
try {
const response = await fetch(`${this.baseUrl}/favorites`, {
method: 'POST',
headers: this.getHeaders(),
body: JSON.stringify(data),
});
if (!response.ok) {
throw new Error(`Error adding favorite: ${response.status}`);
}
return await response.json();
} catch (error) {
console.error('Error adding favorite:', error);
throw error;
}
}
// Toggle favorito (agregar/quitar)
async toggleFavorite(data: CreateFavoriteDto): Promise<{ action: 'added' | 'removed'; favorite?: Favorite }> {
try {
const response = await fetch(`${this.baseUrl}/favorites/toggle`, {
method: 'POST',
headers: this.getHeaders(),
body: JSON.stringify(data),
});
if (!response.ok) {
throw new Error(`Error toggling favorite: ${response.status}`);
}
return await response.json();
} catch (error) {
console.error('Error toggling favorite:', error);
throw error;
}
}
// Obtener un favorito por ID
async getFavoriteById(id: string): Promise<Favorite> {
try {
const response = await fetch(`${this.baseUrl}/favorites/${id}`, {
method: 'GET',
headers: this.getHeaders(),
});
if (!response.ok) {
throw new Error(`Error fetching favorite: ${response.status}`);
}
return await response.json();
} catch (error) {
console.error('Error fetching favorite:', error);
throw error;
}
}
// Actualizar notas de un favorito
async updateFavorite(id: string, data: UpdateFavoriteDto): Promise<Favorite> {
try {
const response = await fetch(`${this.baseUrl}/favorites/${id}`, {
method: 'PATCH',
headers: this.getHeaders(),
body: JSON.stringify(data),
});
if (!response.ok) {
throw new Error(`Error updating favorite: ${response.status}`);
}
return await response.json();
} catch (error) {
console.error('Error updating favorite:', error);
throw error;
}
}
// Eliminar favorito por ID
async removeFavorite(id: string): Promise<void> {
try {
const response = await fetch(`${this.baseUrl}/favorites/${id}`, {
method: 'DELETE',
headers: this.getHeaders(),
});
if (!response.ok) {
throw new Error(`Error removing favorite: ${response.status}`);
}
} catch (error) {
console.error('Error removing favorite:', error);
throw error;
}
}
// Eliminar favorito por item (itemType + itemId)
async removeFavoriteByItem(itemType: FavoriteItemType, itemId: string): Promise<void> {
try {
const response = await fetch(`${this.baseUrl}/favorites/item/${itemType}/${itemId}`, {
method: 'DELETE',
headers: this.getHeaders(),
});
if (!response.ok) {
throw new Error(`Error removing favorite: ${response.status}`);
}
} catch (error) {
console.error('Error removing favorite:', error);
throw error;
}
}
}
export const favoritesApi = new FavoritesApiService();
export default favoritesApi;

View File

@@ -1,290 +1,277 @@
// Mock data for chat system
// Mock data for chat system - Tourist Services
import { User, Chat, Message } from './chatApi';
export const mockChatData = {
users: [
{
id: 'user-1',
name: 'Alexander Kaminski',
email: 'alexander@example.com',
id: 'taxi-1',
name: 'Taxi Central Punta Cana',
email: 'taxicentral@karibeo.com',
avatar: '/api/placeholder/40/40',
online: true,
lastSeen: new Date().toISOString()
lastSeen: new Date().toISOString(),
serviceType: 'taxi'
},
{
id: 'user-2',
name: 'Edwin Martins',
email: 'edwin@example.com',
avatar: '/api/placeholder/40/40',
online: false,
lastSeen: new Date(Date.now() - 3600000).toISOString()
},
{
id: 'user-3',
name: 'Gabriel North',
email: 'gabriel@example.com',
id: 'restaurant-1',
name: 'El Mesón Restaurant',
email: 'elmeson@karibeo.com',
avatar: '/api/placeholder/40/40',
online: true,
lastSeen: new Date().toISOString()
lastSeen: new Date().toISOString(),
serviceType: 'restaurant'
},
{
id: 'user-4',
name: 'Ethan Blackwood',
email: 'ethan@example.com',
avatar: '/api/placeholder/40/40',
online: false,
lastSeen: new Date(Date.now() - 86400000).toISOString()
},
{
id: 'user-5',
name: 'Alexander Steele',
email: 'alexs@example.com',
id: 'hotel-1',
name: 'Paradisus Palma Real',
email: 'paradisus@karibeo.com',
avatar: '/api/placeholder/40/40',
online: true,
lastSeen: new Date().toISOString()
lastSeen: new Date().toISOString(),
serviceType: 'hotel'
},
{
id: 'user-6',
name: 'Marcus Knight',
email: 'marcus@example.com',
id: 'guide-1',
name: 'Carlos - Guía Turístico',
email: 'carlos.guide@karibeo.com',
avatar: '/api/placeholder/40/40',
online: false,
lastSeen: new Date(Date.now() - 1800000).toISOString()
lastSeen: new Date(Date.now() - 3600000).toISOString(),
serviceType: 'guide'
},
{
id: 'user-7',
name: 'Pranoti Deshpande',
email: 'pranoti@example.com',
id: 'politur-1',
name: 'POLITUR Punta Cana',
email: 'politur.pc@karibeo.com',
avatar: '/api/placeholder/40/40',
online: true,
lastSeen: new Date().toISOString()
lastSeen: new Date().toISOString(),
serviceType: 'politur'
},
{
id: 'user-8',
name: 'Sarah Wilson',
email: 'sarah@example.com',
avatar: '/api/placeholder/40/40',
online: false,
lastSeen: new Date(Date.now() - 7200000).toISOString()
},
{
id: 'user-9',
name: 'Michael Chen',
email: 'michael@example.com',
id: 'taxi-2',
name: 'Uber Bávaro',
email: 'uber.bavaro@karibeo.com',
avatar: '/api/placeholder/40/40',
online: true,
lastSeen: new Date().toISOString()
lastSeen: new Date().toISOString(),
serviceType: 'taxi'
},
{
id: 'user-10',
name: 'Lisa Rodriguez',
email: 'lisa@example.com',
id: 'restaurant-2',
name: 'Captain Cook Restaurant',
email: 'captaincook@karibeo.com',
avatar: '/api/placeholder/40/40',
online: false,
lastSeen: new Date(Date.now() - 3600000).toISOString()
lastSeen: new Date(Date.now() - 1800000).toISOString(),
serviceType: 'restaurant'
},
{
id: 'hotel-2',
name: 'Hard Rock Hotel',
email: 'hardrock@karibeo.com',
avatar: '/api/placeholder/40/40',
online: true,
lastSeen: new Date().toISOString(),
serviceType: 'hotel'
},
{
id: 'guide-2',
name: 'María - Guía Cultural',
email: 'maria.guide@karibeo.com',
avatar: '/api/placeholder/40/40',
online: true,
lastSeen: new Date().toISOString(),
serviceType: 'guide'
},
{
id: 'admin-support',
name: 'Soporte Karibeo',
email: 'support@karibeo.com',
avatar: '/api/placeholder/40/40',
online: true,
lastSeen: new Date().toISOString(),
serviceType: 'support'
}
] as User[],
chats: [
{
id: 'chat-1',
name: 'Alexander Kaminski',
id: 'chat-taxi-1',
name: 'Taxi Central Punta Cana',
type: 'direct',
serviceType: 'taxi',
participants: [
{
id: 'user-1',
name: 'Alexander Kaminski',
email: 'alexander@example.com',
id: 'taxi-1',
name: 'Taxi Central Punta Cana',
email: 'taxicentral@karibeo.com',
avatar: '/api/placeholder/40/40',
online: true
}
],
unreadCount: 2,
unreadCount: 1,
lastActivity: new Date(Date.now() - 300000).toISOString(),
avatar: '/api/placeholder/40/40',
online: true,
lastMessage: {
id: 'msg-1',
chatId: 'chat-1',
senderId: 'user-1',
senderName: 'Alexander Kaminski',
id: 'msg-taxi-1',
chatId: 'chat-taxi-1',
senderId: 'taxi-1',
senderName: 'Taxi Central Punta Cana',
senderAvatar: '/api/placeholder/40/40',
content: 'A new feature has been updated to your...',
content: '¡Su taxi llegará en 5 minutos! Vehículo: Toyota Corolla blanco, Placa: A123456',
timestamp: '10:30 AM',
isOwn: false,
status: 'delivered'
}
},
{
id: 'chat-2',
name: 'Edwin Martins',
id: 'chat-restaurant-1',
name: 'El Mesón Restaurant',
type: 'direct',
serviceType: 'restaurant',
participants: [
{
id: 'user-2',
name: 'Edwin Martins',
email: 'edwin@example.com',
id: 'restaurant-1',
name: 'El Mesón Restaurant',
email: 'elmeson@karibeo.com',
avatar: '/api/placeholder/40/40',
online: false
online: true
}
],
unreadCount: 1,
unreadCount: 0,
lastActivity: new Date(Date.now() - 3600000).toISOString(),
avatar: '/api/placeholder/40/40',
online: false,
online: true,
lastMessage: {
id: 'msg-2',
chatId: 'chat-2',
senderId: 'user-2',
senderName: 'Edwin Martins',
id: 'msg-restaurant-1',
chatId: 'chat-restaurant-1',
senderId: 'restaurant-1',
senderName: 'El Mesón Restaurant',
senderAvatar: '/api/placeholder/40/40',
content: 'How can i improve my chances of getting a deposit?',
timestamp: '10:05 PM',
content: 'Su reserva para 4 personas está confirmada para las 8:00 PM',
timestamp: '9:15 AM',
isOwn: false,
status: 'read'
}
},
{
id: 'chat-hotel-1',
name: 'Paradisus Palma Real',
type: 'direct',
serviceType: 'hotel',
participants: [
{
id: 'hotel-1',
name: 'Paradisus Palma Real',
email: 'paradisus@karibeo.com',
avatar: '/api/placeholder/40/40',
online: true
}
],
unreadCount: 2,
lastActivity: new Date(Date.now() - 1800000).toISOString(),
avatar: '/api/placeholder/40/40',
online: true,
lastMessage: {
id: 'msg-hotel-1',
chatId: 'chat-hotel-1',
senderId: 'hotel-1',
senderName: 'Paradisus Palma Real',
senderAvatar: '/api/placeholder/40/40',
content: 'Check-in disponible desde las 3:00 PM. ¿Necesita transporte desde el aeropuerto?',
timestamp: '10:05 AM',
isOwn: false,
status: 'delivered'
}
},
{
id: 'chat-3',
name: 'Gabriel North',
id: 'chat-guide-1',
name: 'Carlos - Guía Turístico',
type: 'direct',
serviceType: 'guide',
participants: [
{
id: 'user-3',
name: 'Gabriel North',
email: 'gabriel@example.com',
id: 'guide-1',
name: 'Carlos - Guía Turístico',
email: 'carlos.guide@karibeo.com',
avatar: '/api/placeholder/40/40',
online: true
online: false
}
],
unreadCount: 0,
lastActivity: new Date(Date.now() - 86400000).toISOString(),
avatar: '/api/placeholder/40/40',
online: true,
online: false,
lastMessage: {
id: 'msg-3',
chatId: 'chat-3',
senderId: 'user-3',
senderName: 'Gabriel North',
id: 'msg-guide-1',
chatId: 'chat-guide-1',
senderId: 'guide-1',
senderName: 'Carlos - Guía Turístico',
senderAvatar: '/api/placeholder/40/40',
content: 'Hey Chris, could i ask you to help me out with variation...',
timestamp: 'Tue',
content: 'Tour a Saona Island confirmado para mañana a las 8 AM. Incluye snorkel y almuerzo.',
timestamp: 'Ayer',
isOwn: false,
status: 'read'
}
},
{
id: 'chat-4',
name: 'Ethan Blackwood',
id: 'chat-politur-1',
name: 'POLITUR Punta Cana',
type: 'direct',
serviceType: 'politur',
participants: [
{
id: 'user-4',
name: 'Ethan Blackwood',
email: 'ethan@example.com',
id: 'politur-1',
name: 'POLITUR Punta Cana',
email: 'politur.pc@karibeo.com',
avatar: '/api/placeholder/40/40',
online: false
online: true
}
],
unreadCount: 0,
lastActivity: new Date(Date.now() - 172800000).toISOString(),
avatar: '/api/placeholder/40/40',
online: false,
online: true,
lastMessage: {
id: 'msg-4',
chatId: 'chat-4',
senderId: 'user-4',
senderName: 'Ethan Blackwood',
id: 'msg-politur-1',
chatId: 'chat-politur-1',
senderId: 'politur-1',
senderName: 'POLITUR Punta Cana',
senderAvatar: '/api/placeholder/40/40',
content: 'By injected humour, or randomised words which...',
timestamp: '1/22/2019',
content: 'Estamos disponibles 24/7 para asistencia turística. ¿En qué podemos ayudarle?',
timestamp: 'Mar 5',
isOwn: false,
status: 'read'
}
},
{
id: 'chat-5',
name: 'Alexander Steele',
id: 'chat-support',
name: 'Soporte Karibeo',
type: 'direct',
serviceType: 'support',
participants: [
{
id: 'user-5',
name: 'Alexander Steele',
email: 'alexs@example.com',
id: 'admin-support',
name: 'Soporte Karibeo',
email: 'support@karibeo.com',
avatar: '/api/placeholder/40/40',
online: true
}
],
unreadCount: 3,
unreadCount: 0,
lastActivity: new Date(Date.now() - 259200000).toISOString(),
avatar: '/api/placeholder/40/40',
online: true,
lastMessage: {
id: 'msg-5',
chatId: 'chat-5',
senderId: 'user-5',
senderName: 'Alexander Steele',
id: 'msg-support-1',
chatId: 'chat-support',
senderId: 'admin-support',
senderName: 'Soporte Karibeo',
senderAvatar: '/api/placeholder/40/40',
content: 'No more running out of the office at 4pm on Fridays!',
timestamp: '1/18/2019',
isOwn: false,
status: 'delivered'
}
},
{
id: 'chat-6',
name: 'Marcus Knight',
type: 'direct',
participants: [
{
id: 'user-6',
name: 'Marcus Knight',
email: 'marcus@example.com',
avatar: '/api/placeholder/40/40',
online: false
}
],
unreadCount: 0,
lastActivity: new Date(Date.now() - 345600000).toISOString(),
avatar: '/api/placeholder/40/40',
online: false,
lastMessage: {
id: 'msg-6',
chatId: 'chat-6',
senderId: 'user-6',
senderName: 'Marcus Knight',
senderAvatar: '/api/placeholder/40/40',
content: 'All your favourite books at your reach! We are now mobile',
timestamp: '1/09/2019',
isOwn: false,
status: 'read'
}
},
{
id: 'chat-7',
name: 'Pranoti Deshpande',
type: 'direct',
participants: [
{
id: 'user-7',
name: 'Pranoti Deshpande',
email: 'pranoti@example.com',
avatar: '/api/placeholder/40/40',
online: true
}
],
unreadCount: 0,
lastActivity: new Date(Date.now() - 432000000).toISOString(),
avatar: '/api/placeholder/40/40',
online: true,
lastMessage: {
id: 'msg-7',
chatId: 'chat-7',
senderId: 'user-7',
senderName: 'Pranoti Deshpande',
senderAvatar: '/api/placeholder/40/40',
content: 'Dear Deborah, your Thai massage is today at 5pm.',
timestamp: 'Feb 9',
content: 'Bienvenido a Karibeo. ¿En qué podemos ayudarle hoy?',
timestamp: 'Mar 3',
isOwn: false,
status: 'read'
}
@@ -292,96 +279,152 @@ export const mockChatData = {
] as Chat[],
messages: [
// Chat 1 messages
// Taxi Chat messages
{
id: 'msg-1-1',
chatId: 'chat-1',
senderId: 'user-1',
senderName: 'Alexander Kaminski',
id: 'msg-taxi-1-1',
chatId: 'chat-taxi-1',
senderId: 'current-user',
senderName: 'You',
senderAvatar: '/api/placeholder/40/40',
content: 'Hello! How are you doing today?',
content: 'Necesito un taxi al aeropuerto internacional de Punta Cana',
timestamp: '10:15 AM',
isOwn: true,
status: 'read'
},
{
id: 'msg-taxi-1-2',
chatId: 'chat-taxi-1',
senderId: 'taxi-1',
senderName: 'Taxi Central Punta Cana',
senderAvatar: '/api/placeholder/40/40',
content: 'Perfecto. ¿A qué hora necesita el servicio?',
timestamp: '10:18 AM',
isOwn: false,
status: 'delivered'
},
{
id: 'msg-taxi-1-3',
chatId: 'chat-taxi-1',
senderId: 'current-user',
senderName: 'You',
senderAvatar: '/api/placeholder/40/40',
content: 'A las 2:00 PM por favor',
timestamp: '10:20 AM',
isOwn: true,
status: 'read'
},
{
id: 'msg-taxi-1-4',
chatId: 'chat-taxi-1',
senderId: 'taxi-1',
senderName: 'Taxi Central Punta Cana',
senderAvatar: '/api/placeholder/40/40',
content: '¡Su taxi llegará en 5 minutos! Vehículo: Toyota Corolla blanco, Placa: A123456',
timestamp: '10:30 AM',
isOwn: false,
status: 'delivered'
},
// Restaurant Chat messages
{
id: 'msg-1-2',
chatId: 'chat-1',
id: 'msg-restaurant-1-1',
chatId: 'chat-restaurant-1',
senderId: 'current-user',
senderName: 'You',
senderAvatar: '/api/placeholder/40/40',
content: "Hi! I'm doing great, thank you for asking. How about you?",
timestamp: '10:35 AM',
content: 'Hola, quisiera hacer una reserva para esta noche',
timestamp: '9:00 AM',
isOwn: true,
status: 'read'
},
{
id: 'msg-1-3',
chatId: 'chat-1',
senderId: 'user-1',
senderName: 'Alexander Kaminski',
id: 'msg-restaurant-1-2',
chatId: 'chat-restaurant-1',
senderId: 'restaurant-1',
senderName: 'El Mesón Restaurant',
senderAvatar: '/api/placeholder/40/40',
content: 'A new feature has been updated to your account. Would you like me to show you around?',
timestamp: '10:36 AM',
content: '¡Hola! Claro que sí. ¿Para cuántas personas y a qué hora?',
timestamp: '9:10 AM',
isOwn: false,
status: 'delivered'
},
{
id: 'msg-1-4',
chatId: 'chat-1',
id: 'msg-restaurant-1-3',
chatId: 'chat-restaurant-1',
senderId: 'current-user',
senderName: 'You',
senderAvatar: '/api/placeholder/40/40',
content: "That sounds great! I'd love to hear more about it.",
timestamp: '10:40 AM',
content: 'Para 4 personas a las 8:00 PM',
timestamp: '9:12 AM',
isOwn: true,
status: 'read'
},
// Chat 2 messages
{
id: 'msg-2-1',
chatId: 'chat-2',
senderId: 'user-2',
senderName: 'Edwin Martins',
id: 'msg-restaurant-1-4',
chatId: 'chat-restaurant-1',
senderId: 'restaurant-1',
senderName: 'El Mesón Restaurant',
senderAvatar: '/api/placeholder/40/40',
content: 'How can i improve my chances of getting a deposit?',
timestamp: '10:05 PM',
content: 'Su reserva para 4 personas está confirmada para las 8:00 PM. ¿Tiene alguna preferencia dietética?',
timestamp: '9:15 AM',
isOwn: false,
status: 'read'
},
// Hotel Chat messages
{
id: 'msg-hotel-1-1',
chatId: 'chat-hotel-1',
senderId: 'hotel-1',
senderName: 'Paradisus Palma Real',
senderAvatar: '/api/placeholder/40/40',
content: 'Bienvenido a Paradisus Palma Real. Su habitación estará lista pronto.',
timestamp: '10:00 AM',
isOwn: false,
status: 'delivered'
},
{
id: 'msg-2-2',
chatId: 'chat-2',
id: 'msg-hotel-1-2',
chatId: 'chat-hotel-1',
senderId: 'current-user',
senderName: 'You',
senderAvatar: '/api/placeholder/40/40',
content: 'There are several ways to improve your chances. First, make sure your profile is complete...',
timestamp: '10:10 PM',
content: 'Gracias. ¿A qué hora es el check-in?',
timestamp: '10:03 AM',
isOwn: true,
status: 'read'
},
// Chat 3 messages
{
id: 'msg-3-1',
chatId: 'chat-3',
senderId: 'user-3',
senderName: 'Gabriel North',
id: 'msg-hotel-1-3',
chatId: 'chat-hotel-1',
senderId: 'hotel-1',
senderName: 'Paradisus Palma Real',
senderAvatar: '/api/placeholder/40/40',
content: 'Hey Chris, could i ask you to help me out with variation of our main design theme?',
content: 'Check-in disponible desde las 3:00 PM. ¿Necesita transporte desde el aeropuerto?',
timestamp: '10:05 AM',
isOwn: false,
status: 'delivered'
},
// Guide Chat messages
{
id: 'msg-guide-1-1',
chatId: 'chat-guide-1',
senderId: 'current-user',
senderName: 'You',
senderAvatar: '/api/placeholder/40/40',
content: 'Me gustaría reservar el tour a Saona Island',
timestamp: '2:00 PM',
isOwn: true,
status: 'read'
},
{
id: 'msg-guide-1-2',
chatId: 'chat-guide-1',
senderId: 'guide-1',
senderName: 'Carlos - Guía Turístico',
senderAvatar: '/api/placeholder/40/40',
content: 'Excelente elección! Tenemos disponibilidad para mañana. El tour incluye transporte, snorkel, y almuerzo buffet.',
timestamp: '2:15 PM',
isOwn: false,
status: 'read'
},
{
id: 'msg-3-2',
chatId: 'chat-3',
senderId: 'current-user',
senderName: 'You',
senderAvatar: '/api/placeholder/40/40',
content: 'Of course! What specific variations are you looking for?',
timestamp: '2:20 PM',
isOwn: true,
status: 'read'
}
] as Message[]
};

View File

@@ -0,0 +1,244 @@
/**
* Notifications API Service
* Conectado a la API real de Karibeo - Fase 2
*/
import { API_BASE_URL } from './config';
export type NotificationType = 'reservation' | 'review' | 'payment' | 'system' | 'promotion' | 'alert' | 'message';
export interface Notification {
id: string;
userId: string;
type: NotificationType;
title: string;
message: string;
data?: Record<string, any>;
isRead: boolean;
readAt?: string;
createdAt: string;
}
export interface NotificationStats {
total: number;
unread: number;
byType: Record<NotificationType, number>;
}
export interface CreateNotificationDto {
type: NotificationType;
title: string;
message: string;
data?: Record<string, any>;
targetUserId?: string;
targetRole?: string;
}
class NotificationsApiService {
private getToken(): string | null {
return localStorage.getItem('karibeo_token') || localStorage.getItem('karibeo-token');
}
private getHeaders(): HeadersInit {
const token = this.getToken();
return {
'Content-Type': 'application/json',
...(token && { Authorization: `Bearer ${token}` }),
};
}
// Obtener mis notificaciones
async getMyNotifications(options?: {
type?: NotificationType;
isRead?: boolean;
page?: number;
limit?: number;
}): Promise<{ notifications: Notification[]; total: number; unreadCount: number }> {
try {
const params = new URLSearchParams();
if (options?.type) params.append('type', options.type);
if (options?.isRead !== undefined) params.append('isRead', options.isRead.toString());
if (options?.page) params.append('page', options.page.toString());
if (options?.limit) params.append('limit', options.limit.toString());
const url = `${API_BASE_URL}/notifications/my?${params}`;
const response = await fetch(url, {
method: 'GET',
headers: this.getHeaders(),
});
if (!response.ok) {
throw new Error(`Error fetching notifications: ${response.status}`);
}
return await response.json();
} catch (error) {
console.error('Error fetching notifications:', error);
throw error;
}
}
// Obtener conteo de notificaciones no leidas
async getUnreadCount(): Promise<number> {
try {
const response = await fetch(`${API_BASE_URL}/notifications/unread-count`, {
method: 'GET',
headers: this.getHeaders(),
});
if (!response.ok) {
throw new Error(`Error fetching unread count: ${response.status}`);
}
const data = await response.json();
return data.count || 0;
} catch (error) {
console.error('Error fetching unread count:', error);
return 0;
}
}
// Marcar notificacion como leida
async markAsRead(notificationId: string): Promise<Notification> {
try {
const response = await fetch(`${API_BASE_URL}/notifications/${notificationId}/read`, {
method: 'PATCH',
headers: this.getHeaders(),
});
if (!response.ok) {
throw new Error(`Error marking notification as read: ${response.status}`);
}
return await response.json();
} catch (error) {
console.error('Error marking notification as read:', error);
throw error;
}
}
// Marcar todas como leidas
async markAllAsRead(): Promise<{ count: number }> {
try {
const response = await fetch(`${API_BASE_URL}/notifications/read-all`, {
method: 'PATCH',
headers: this.getHeaders(),
});
if (!response.ok) {
throw new Error(`Error marking all notifications as read: ${response.status}`);
}
return await response.json();
} catch (error) {
console.error('Error marking all notifications as read:', error);
throw error;
}
}
// Eliminar notificacion
async deleteNotification(notificationId: string): Promise<void> {
try {
const response = await fetch(`${API_BASE_URL}/notifications/${notificationId}`, {
method: 'DELETE',
headers: this.getHeaders(),
});
if (!response.ok) {
throw new Error(`Error deleting notification: ${response.status}`);
}
} catch (error) {
console.error('Error deleting notification:', error);
throw error;
}
}
// Eliminar todas las notificaciones leidas
async deleteAllRead(): Promise<{ count: number }> {
try {
const response = await fetch(`${API_BASE_URL}/notifications/clear-read`, {
method: 'DELETE',
headers: this.getHeaders(),
});
if (!response.ok) {
throw new Error(`Error deleting read notifications: ${response.status}`);
}
return await response.json();
} catch (error) {
console.error('Error deleting read notifications:', error);
throw error;
}
}
// === ADMIN METHODS ===
// Crear notificacion (admin)
async createNotification(data: CreateNotificationDto): Promise<Notification> {
try {
const response = await fetch(`${API_BASE_URL}/notifications`, {
method: 'POST',
headers: this.getHeaders(),
body: JSON.stringify(data),
});
if (!response.ok) {
throw new Error(`Error creating notification: ${response.status}`);
}
return await response.json();
} catch (error) {
console.error('Error creating notification:', error);
throw error;
}
}
// Enviar notificacion masiva (admin)
async sendBulkNotification(data: {
type: NotificationType;
title: string;
message: string;
targetRoles?: string[];
targetUserIds?: string[];
}): Promise<{ sent: number }> {
try {
const response = await fetch(`${API_BASE_URL}/notifications/bulk`, {
method: 'POST',
headers: this.getHeaders(),
body: JSON.stringify(data),
});
if (!response.ok) {
throw new Error(`Error sending bulk notification: ${response.status}`);
}
return await response.json();
} catch (error) {
console.error('Error sending bulk notification:', error);
throw error;
}
}
// Obtener estadisticas de notificaciones (admin)
async getNotificationStats(): Promise<NotificationStats> {
try {
const response = await fetch(`${API_BASE_URL}/notifications/stats`, {
method: 'GET',
headers: this.getHeaders(),
});
if (!response.ok) {
throw new Error(`Error fetching notification stats: ${response.status}`);
}
return await response.json();
} catch (error) {
console.error('Error fetching notification stats:', error);
throw error;
}
}
}
export const notificationsApi = new NotificationsApiService();
export default notificationsApi;

191
src/services/quizApi.ts Normal file
View File

@@ -0,0 +1,191 @@
/**
* Quiz API Service
* Conectado a la API real de Karibeo - Fase 1
*/
import { API_CONFIG } from '@/config/api';
const API_BASE_URL = API_CONFIG.BASE_URL;
export interface QuizQuestion {
id: string;
question: string;
type: 'single' | 'multiple' | 'scale';
options: QuizOption[];
category: string;
order: number;
}
export interface QuizOption {
id: string;
label: string;
value: string;
icon?: string;
description?: string;
}
export interface QuizResponse {
id: string;
userId: string;
travelStyles: string[];
preferredActivities: string[];
accommodationPreferences: string[];
budgetRange: string;
groupType: string;
cuisinePreferences: string[];
travelPersona: string;
personaDescription: string;
isCompleted: boolean;
completedAt?: string;
createdAt: string;
}
export interface SubmitQuizDto {
answers: QuizAnswer[];
}
export interface QuizAnswer {
questionId: string;
selectedOptions: string[];
}
// Travel Personas que puede generar el quiz
export type TravelPersona =
| 'Adventure Explorer'
| 'Luxury Connoisseur'
| 'Cultural Enthusiast'
| 'Beach Relaxer'
| 'Foodie Traveler'
| 'Nature Lover'
| 'Urban Explorer'
| 'Budget Backpacker'
| 'Family Vacationer'
| 'Romantic Getaway';
class QuizApiService {
private baseUrl: string;
constructor() {
this.baseUrl = API_BASE_URL;
}
private getToken(): string | null {
return localStorage.getItem('karibeo_token');
}
private getHeaders(): HeadersInit {
const token = this.getToken();
return {
'Content-Type': 'application/json',
...(token && { Authorization: `Bearer ${token}` }),
};
}
// Obtener preguntas del quiz
async getQuestions(): Promise<QuizQuestion[]> {
try {
const response = await fetch(`${this.baseUrl}/quiz/questions`, {
method: 'GET',
headers: this.getHeaders(),
});
if (!response.ok) {
throw new Error(`Error fetching quiz questions: ${response.status}`);
}
return await response.json();
} catch (error) {
console.error('Error fetching quiz questions:', error);
throw error;
}
}
// Obtener mi respuesta del quiz
async getMyQuizResponse(): Promise<QuizResponse | null> {
try {
const response = await fetch(`${this.baseUrl}/quiz/my`, {
method: 'GET',
headers: this.getHeaders(),
});
if (response.status === 404) {
return null;
}
if (!response.ok) {
throw new Error(`Error fetching quiz response: ${response.status}`);
}
return await response.json();
} catch (error) {
console.error('Error fetching quiz response:', error);
throw error;
}
}
// Enviar respuestas del quiz
async submitQuiz(data: SubmitQuizDto): Promise<QuizResponse> {
try {
const response = await fetch(`${this.baseUrl}/quiz/submit`, {
method: 'POST',
headers: this.getHeaders(),
body: JSON.stringify(data),
});
if (!response.ok) {
throw new Error(`Error submitting quiz: ${response.status}`);
}
return await response.json();
} catch (error) {
console.error('Error submitting quiz:', error);
throw error;
}
}
// Reiniciar quiz (para volver a tomarlo)
async resetQuiz(): Promise<void> {
try {
const response = await fetch(`${this.baseUrl}/quiz/reset`, {
method: 'DELETE',
headers: this.getHeaders(),
});
if (!response.ok) {
throw new Error(`Error resetting quiz: ${response.status}`);
}
} catch (error) {
console.error('Error resetting quiz:', error);
throw error;
}
}
// Verificar si el usuario ya completó el quiz
async hasCompletedQuiz(): Promise<boolean> {
try {
const response = await this.getMyQuizResponse();
return response?.isCompleted ?? false;
} catch (error) {
return false;
}
}
// Obtener la Travel Persona del usuario
async getTravelPersona(): Promise<{ persona: string; description: string } | null> {
try {
const response = await this.getMyQuizResponse();
if (response?.isCompleted && response.travelPersona) {
return {
persona: response.travelPersona,
description: response.personaDescription,
};
}
return null;
} catch (error) {
return null;
}
}
}
export const quizApi = new QuizApiService();
export default quizApi;

View File

@@ -1,4 +1,4 @@
const API_BASE_URL = 'https://karibeo.lesoluciones.net:8443/api';
import { API_BASE_URL } from './config';
export interface Review {
id: string;

View File

@@ -1,4 +1,4 @@
const API_BASE_URL = 'https://karibeo.lesoluciones.net:8443/api';
import { API_BASE_URL } from './config';
export interface TourismOffer {
id: string;

408
src/services/tripsApi.ts Normal file
View File

@@ -0,0 +1,408 @@
/**
* Trips API Service
* Conectado a la API real de Karibeo - Fase 1
*/
import { API_CONFIG } from '@/config/api';
const API_BASE_URL = API_CONFIG.BASE_URL;
export type TripStatus = 'planning' | 'upcoming' | 'in_progress' | 'completed' | 'cancelled';
export interface TripActivity {
id: string;
dayId: string;
title: string;
description?: string;
startTime?: string;
endTime?: string;
location?: string;
locationCoords?: { lat: number; lng: number };
itemId?: string;
itemType?: string;
estimatedCost?: number;
sortOrder: number;
createdAt: string;
}
export interface TripDay {
id: string;
tripId: string;
dayNumber: number;
date?: string;
title?: string;
notes?: string;
activities?: TripActivity[];
createdAt: string;
}
export interface Trip {
id: string;
userId: string;
name: string;
description?: string;
coverImageUrl?: string;
startDate?: string;
endDate?: string;
destination?: string;
travelersCount: number;
estimatedBudget?: number;
currency: string;
status: TripStatus;
isPublic: boolean;
days?: TripDay[];
createdAt: string;
updatedAt: string;
}
export interface TripStats {
totalTrips: number;
byStatus: Record<TripStatus, number>;
upcomingTrips: number;
completedTrips: number;
}
export interface CreateTripDto {
name: string;
description?: string;
coverImageUrl?: string;
startDate?: string;
endDate?: string;
destination?: string;
travelersCount?: number;
estimatedBudget?: number;
currency?: string;
isPublic?: boolean;
}
export interface UpdateTripDto {
name?: string;
description?: string;
coverImageUrl?: string;
startDate?: string;
endDate?: string;
destination?: string;
travelersCount?: number;
estimatedBudget?: number;
currency?: string;
status?: TripStatus;
isPublic?: boolean;
}
export interface CreateTripDayDto {
dayNumber: number;
date?: string;
title?: string;
notes?: string;
}
export interface UpdateTripDayDto {
dayNumber?: number;
date?: string;
title?: string;
notes?: string;
}
export interface CreateTripActivityDto {
title: string;
description?: string;
startTime?: string;
endTime?: string;
location?: string;
locationCoords?: { lat: number; lng: number };
itemId?: string;
itemType?: string;
estimatedCost?: number;
}
export interface UpdateTripActivityDto {
title?: string;
description?: string;
startTime?: string;
endTime?: string;
location?: string;
locationCoords?: { lat: number; lng: number };
itemId?: string;
itemType?: string;
estimatedCost?: number;
}
class TripsApiService {
private baseUrl: string;
constructor() {
this.baseUrl = API_BASE_URL;
}
private getToken(): string | null {
return localStorage.getItem('karibeo_token');
}
private getHeaders(): HeadersInit {
const token = this.getToken();
return {
'Content-Type': 'application/json',
...(token && { Authorization: `Bearer ${token}` }),
};
}
// ============ TRIPS ============
// Obtener mis viajes
async getMyTrips(status?: TripStatus): Promise<Trip[]> {
try {
const url = new URL(`${this.baseUrl}/trips/my`);
if (status) {
url.searchParams.append('status', status);
}
const response = await fetch(url.toString(), {
method: 'GET',
headers: this.getHeaders(),
});
if (!response.ok) {
throw new Error(`Error fetching trips: ${response.status}`);
}
return await response.json();
} catch (error) {
console.error('Error fetching trips:', error);
throw error;
}
}
// Obtener estadísticas de viajes
async getTripsStats(): Promise<TripStats> {
try {
const response = await fetch(`${this.baseUrl}/trips/my/stats`, {
method: 'GET',
headers: this.getHeaders(),
});
if (!response.ok) {
throw new Error(`Error fetching trips stats: ${response.status}`);
}
return await response.json();
} catch (error) {
console.error('Error fetching trips stats:', error);
throw error;
}
}
// Crear viaje
async createTrip(data: CreateTripDto): Promise<Trip> {
try {
const response = await fetch(`${this.baseUrl}/trips`, {
method: 'POST',
headers: this.getHeaders(),
body: JSON.stringify(data),
});
if (!response.ok) {
throw new Error(`Error creating trip: ${response.status}`);
}
return await response.json();
} catch (error) {
console.error('Error creating trip:', error);
throw error;
}
}
// Obtener viaje por ID
async getTripById(id: string): Promise<Trip> {
try {
const response = await fetch(`${this.baseUrl}/trips/${id}`, {
method: 'GET',
headers: this.getHeaders(),
});
if (!response.ok) {
throw new Error(`Error fetching trip: ${response.status}`);
}
return await response.json();
} catch (error) {
console.error('Error fetching trip:', error);
throw error;
}
}
// Actualizar viaje
async updateTrip(id: string, data: UpdateTripDto): Promise<Trip> {
try {
const response = await fetch(`${this.baseUrl}/trips/${id}`, {
method: 'PATCH',
headers: this.getHeaders(),
body: JSON.stringify(data),
});
if (!response.ok) {
throw new Error(`Error updating trip: ${response.status}`);
}
return await response.json();
} catch (error) {
console.error('Error updating trip:', error);
throw error;
}
}
// Eliminar viaje
async deleteTrip(id: string): Promise<void> {
try {
const response = await fetch(`${this.baseUrl}/trips/${id}`, {
method: 'DELETE',
headers: this.getHeaders(),
});
if (!response.ok) {
throw new Error(`Error deleting trip: ${response.status}`);
}
} catch (error) {
console.error('Error deleting trip:', error);
throw error;
}
}
// ============ DAYS ============
// Agregar día al viaje
async addDay(tripId: string, data: CreateTripDayDto): Promise<TripDay> {
try {
const response = await fetch(`${this.baseUrl}/trips/${tripId}/days`, {
method: 'POST',
headers: this.getHeaders(),
body: JSON.stringify(data),
});
if (!response.ok) {
throw new Error(`Error adding day: ${response.status}`);
}
return await response.json();
} catch (error) {
console.error('Error adding day:', error);
throw error;
}
}
// Actualizar día
async updateDay(tripId: string, dayId: string, data: UpdateTripDayDto): Promise<TripDay> {
try {
const response = await fetch(`${this.baseUrl}/trips/${tripId}/days/${dayId}`, {
method: 'PATCH',
headers: this.getHeaders(),
body: JSON.stringify(data),
});
if (!response.ok) {
throw new Error(`Error updating day: ${response.status}`);
}
return await response.json();
} catch (error) {
console.error('Error updating day:', error);
throw error;
}
}
// Eliminar día
async deleteDay(tripId: string, dayId: string): Promise<void> {
try {
const response = await fetch(`${this.baseUrl}/trips/${tripId}/days/${dayId}`, {
method: 'DELETE',
headers: this.getHeaders(),
});
if (!response.ok) {
throw new Error(`Error deleting day: ${response.status}`);
}
} catch (error) {
console.error('Error deleting day:', error);
throw error;
}
}
// ============ ACTIVITIES ============
// Agregar actividad
async addActivity(tripId: string, dayId: string, data: CreateTripActivityDto): Promise<TripActivity> {
try {
const response = await fetch(`${this.baseUrl}/trips/${tripId}/days/${dayId}/activities`, {
method: 'POST',
headers: this.getHeaders(),
body: JSON.stringify(data),
});
if (!response.ok) {
throw new Error(`Error adding activity: ${response.status}`);
}
return await response.json();
} catch (error) {
console.error('Error adding activity:', error);
throw error;
}
}
// Actualizar actividad
async updateActivity(tripId: string, dayId: string, activityId: string, data: UpdateTripActivityDto): Promise<TripActivity> {
try {
const response = await fetch(`${this.baseUrl}/trips/${tripId}/days/${dayId}/activities/${activityId}`, {
method: 'PATCH',
headers: this.getHeaders(),
body: JSON.stringify(data),
});
if (!response.ok) {
throw new Error(`Error updating activity: ${response.status}`);
}
return await response.json();
} catch (error) {
console.error('Error updating activity:', error);
throw error;
}
}
// Eliminar actividad
async deleteActivity(tripId: string, dayId: string, activityId: string): Promise<void> {
try {
const response = await fetch(`${this.baseUrl}/trips/${tripId}/days/${dayId}/activities/${activityId}`, {
method: 'DELETE',
headers: this.getHeaders(),
});
if (!response.ok) {
throw new Error(`Error deleting activity: ${response.status}`);
}
} catch (error) {
console.error('Error deleting activity:', error);
throw error;
}
}
// Reordenar actividades
async reorderActivities(tripId: string, dayId: string, activityIds: string[]): Promise<void> {
try {
const response = await fetch(`${this.baseUrl}/trips/${tripId}/days/${dayId}/activities/order`, {
method: 'PATCH',
headers: this.getHeaders(),
body: JSON.stringify({ activityIds }),
});
if (!response.ok) {
throw new Error(`Error reordering activities: ${response.status}`);
}
} catch (error) {
console.error('Error reordering activities:', error);
throw error;
}
}
}
export const tripsApi = new TripsApiService();
export default tripsApi;

63
src/types/roles.ts Normal file
View File

@@ -0,0 +1,63 @@
export type EntityType = 'admin' | 'hotel' | 'restaurant' | 'commerce';
export type Permission = {
id: string;
name: string;
description: string;
module: string;
};
export type Role = {
id: string;
name: string;
entityType: EntityType;
description: string;
permissions: string[];
userCount: number;
isSystem?: boolean;
};
export type UserRole = {
userId: string;
roleId: string;
entityId?: string;
assignedAt: string;
assignedBy: string;
};
export const PERMISSIONS: Record<string, Permission[]> = {
admin: [
{ id: 'admin.users.read', name: 'View Users', description: 'View user information', module: 'User Management' },
{ id: 'admin.users.write', name: 'Manage Users', description: 'Create and edit users', module: 'User Management' },
{ id: 'admin.users.delete', name: 'Delete Users', description: 'Delete user accounts', module: 'User Management' },
{ id: 'admin.roles.read', name: 'View Roles', description: 'View roles and permissions', module: 'Role Management' },
{ id: 'admin.roles.write', name: 'Manage Roles', description: 'Create and edit roles', module: 'Role Management' },
{ id: 'admin.content.write', name: 'Manage Content', description: 'Create and edit content', module: 'Content Management' },
{ id: 'admin.finance.read', name: 'View Financial Data', description: 'View financial reports', module: 'Financial' },
{ id: 'admin.settings.write', name: 'Manage Settings', description: 'Configure system settings', module: 'Settings' },
],
hotel: [
{ id: 'hotel.bookings.read', name: 'View Bookings', description: 'View hotel bookings', module: 'Bookings' },
{ id: 'hotel.bookings.write', name: 'Manage Bookings', description: 'Create and modify bookings', module: 'Bookings' },
{ id: 'hotel.rooms.write', name: 'Manage Rooms', description: 'Manage room inventory', module: 'Rooms' },
{ id: 'hotel.guests.read', name: 'View Guests', description: 'View guest information', module: 'Guests' },
{ id: 'hotel.staff.write', name: 'Manage Staff', description: 'Manage hotel staff', module: 'Staff' },
{ id: 'hotel.reports.read', name: 'View Reports', description: 'View hotel reports', module: 'Reports' },
],
restaurant: [
{ id: 'restaurant.orders.read', name: 'View Orders', description: 'View restaurant orders', module: 'Orders' },
{ id: 'restaurant.orders.write', name: 'Manage Orders', description: 'Create and modify orders', module: 'Orders' },
{ id: 'restaurant.menu.write', name: 'Manage Menu', description: 'Update menu items', module: 'Menu' },
{ id: 'restaurant.reservations.write', name: 'Manage Reservations', description: 'Handle table reservations', module: 'Reservations' },
{ id: 'restaurant.staff.write', name: 'Manage Staff', description: 'Manage restaurant staff', module: 'Staff' },
{ id: 'restaurant.inventory.read', name: 'View Inventory', description: 'View inventory levels', module: 'Inventory' },
],
commerce: [
{ id: 'commerce.products.read', name: 'View Products', description: 'View product catalog', module: 'Products' },
{ id: 'commerce.products.write', name: 'Manage Products', description: 'Create and edit products', module: 'Products' },
{ id: 'commerce.orders.read', name: 'View Orders', description: 'View customer orders', module: 'Orders' },
{ id: 'commerce.orders.write', name: 'Manage Orders', description: 'Process and manage orders', module: 'Orders' },
{ id: 'commerce.inventory.write', name: 'Manage Inventory', description: 'Update inventory levels', module: 'Inventory' },
{ id: 'commerce.promotions.write', name: 'Manage Promotions', description: 'Create promotions and discounts', module: 'Marketing' },
],
};

93
src/utils/responsive.ts Normal file
View File

@@ -0,0 +1,93 @@
/**
* Responsive utility functions
* Centralized helpers for responsive behavior
*/
/**
* Get responsive class names based on condition
*/
export function responsiveClass(
base: string,
mobile?: string,
tablet?: string,
desktop?: string
): string {
const classes = [base];
if (mobile) classes.push(`max-md:${mobile}`);
if (tablet) classes.push(`md:${tablet} max-lg:${tablet}`);
if (desktop) classes.push(`lg:${desktop}`);
return classes.join(' ');
}
/**
* Get responsive value based on screen width
*/
export function getResponsiveValue<T>(
mobile: T,
tablet: T,
desktop: T,
width: number
): T {
if (width < 768) return mobile;
if (width < 1024) return tablet;
return desktop;
}
/**
* Debounce function for resize events
*/
export function debounce<T extends (...args: any[]) => any>(
func: T,
wait: number
): (...args: Parameters<T>) => void {
let timeout: NodeJS.Timeout;
return (...args: Parameters<T>) => {
clearTimeout(timeout);
timeout = setTimeout(() => func(...args), wait);
};
}
/**
* Check if device supports touch
*/
export function isTouchDevice(): boolean {
return (
'ontouchstart' in window ||
navigator.maxTouchPoints > 0 ||
(navigator as any).msMaxTouchPoints > 0
);
}
/**
* Get optimal column count for grid based on width
*/
export function getGridColumns(width: number, itemMinWidth: number = 300): number {
return Math.max(1, Math.floor(width / itemMinWidth));
}
/**
* Format text for mobile (truncate if needed)
*/
export function formatTextForMobile(
text: string,
maxLength: number = 100,
isMobile: boolean
): string {
if (!isMobile || text.length <= maxLength) return text;
return text.substring(0, maxLength) + '...';
}
/**
* Get responsive font size
*/
export function getResponsiveFontSize(
baseSize: number,
width: number
): number {
if (width < 768) return baseSize * 0.875; // 87.5% for mobile
if (width < 1024) return baseSize * 0.9375; // 93.75% for tablet
return baseSize;
}