Compare commits
10 Commits
32caa477e4
...
951a1f64ac
| Author | SHA1 | Date | |
|---|---|---|---|
| 951a1f64ac | |||
|
|
6b1e9a25af | ||
|
|
bdb8b2d7e2 | ||
|
|
4bd776e3bd | ||
|
|
5e25164a56 | ||
|
|
6793ea6e3e | ||
|
|
106e4d852d | ||
|
|
2f6983aa41 | ||
|
|
ff1312ae50 | ||
|
|
45963fa7ba |
387
README.md
387
README.md
@@ -1,73 +1,368 @@
|
|||||||
# Welcome to your Lovable project
|
# 🌴 Karibeo - Tourism & Business Management Platform
|
||||||
|
|
||||||
## Project info
|
[](https://reactjs.org/)
|
||||||
|
[](https://www.typescriptlang.org/)
|
||||||
|
[](https://tailwindcss.com/)
|
||||||
|
[](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
|
### Frontend
|
||||||
# Step 1: Clone the repository using the project's Git URL.
|
- **Framework**: React 18.3.1 + TypeScript
|
||||||
git clone <YOUR_GIT_URL>
|
- **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.
|
### Dependencias Clave
|
||||||
cd <YOUR_PROJECT_NAME>
|
```json
|
||||||
|
{
|
||||||
# Step 3: Install the necessary dependencies.
|
"@tanstack/react-query": "^5.83.0",
|
||||||
npm i
|
"@googlemaps/js-api-loader": "^1.16.10",
|
||||||
|
"react-hook-form": "^7.61.1",
|
||||||
# Step 4: Start the development server with auto-reloading and an instant preview.
|
"zod": "^3.25.76",
|
||||||
npm run dev
|
"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.
|
karibeo/
|
||||||
- Make your changes and commit the changes.
|
├── 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.
|
### Prerrequisitos
|
||||||
- Click on the "Code" button (green button) near the top right.
|
- Node.js 18+ (recomendado usar [nvm](https://github.com/nvm-sh/nvm))
|
||||||
- Select the "Codespaces" tab.
|
- npm o yarn
|
||||||
- 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.
|
|
||||||
|
|
||||||
## 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
|
# 2. Instalar dependencias
|
||||||
- TypeScript
|
npm install
|
||||||
- React
|
|
||||||
- shadcn-ui
|
|
||||||
- Tailwind CSS
|
|
||||||
|
|
||||||
## 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
729
docs/API.md
Normal 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
428
docs/ARCHITECTURE.md
Normal 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
608
docs/DEVELOPMENT.md
Normal 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
324
docs/RESPONSIVE.md
Normal 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
588
docs/USER-GUIDE.md
Normal 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
|
||||||
92
src/App.tsx
92
src/App.tsx
@@ -29,6 +29,10 @@ import Messages from "./pages/dashboard/Messages";
|
|||||||
import Reviews from "./pages/dashboard/Reviews";
|
import Reviews from "./pages/dashboard/Reviews";
|
||||||
import Bookings from "./pages/dashboard/Bookings";
|
import Bookings from "./pages/dashboard/Bookings";
|
||||||
import Bookmarks from "./pages/dashboard/Bookmarks";
|
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 Profile from "./pages/dashboard/Profile";
|
||||||
import Settings from "./pages/dashboard/Settings";
|
import Settings from "./pages/dashboard/Settings";
|
||||||
import Invoices from "./pages/dashboard/Invoices";
|
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 CRMContacts from "./pages/dashboard/crm/Contacts";
|
||||||
import CRMCampaigns from "./pages/dashboard/crm/Campaigns";
|
import CRMCampaigns from "./pages/dashboard/crm/Campaigns";
|
||||||
import CRMAnalytics from "./pages/dashboard/crm/Analytics";
|
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
|
// Tourist App
|
||||||
import TouristApp from "./pages/TouristApp";
|
import TouristApp from "./pages/TouristApp";
|
||||||
// Commerce pages (for retail stores)
|
// Commerce pages (for retail stores)
|
||||||
@@ -124,10 +132,8 @@ const DashboardGate = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const role = (user as any)?.role;
|
const role = (user as any)?.role;
|
||||||
console.log('🚪 DashboardGate - checking role:', role, 'isAdmin?', (role === 'admin' || role === 'super_admin'));
|
|
||||||
|
|
||||||
if (role === 'admin' || role === 'super_admin') {
|
if (role === 'admin' || role === 'super_admin') {
|
||||||
console.log('🚪 Redirecting to admin dashboard');
|
|
||||||
return <Navigate to="/dashboard/admin" replace />;
|
return <Navigate to="/dashboard/admin" replace />;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -270,6 +276,39 @@ const AppRouter = () => (
|
|||||||
</ProtectedRoute>
|
</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={
|
<Route path="/dashboard/profile" element={
|
||||||
<ProtectedRoute>
|
<ProtectedRoute>
|
||||||
<DashboardLayout>
|
<DashboardLayout>
|
||||||
@@ -350,6 +389,14 @@ const AppRouter = () => (
|
|||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
} />
|
} />
|
||||||
|
|
||||||
|
<Route path="/dashboard/roles-permissions" element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<DashboardLayout>
|
||||||
|
<RolesPermissions />
|
||||||
|
</DashboardLayout>
|
||||||
|
</ProtectedRoute>
|
||||||
|
} />
|
||||||
|
|
||||||
{/* Commerce Routes */}
|
{/* Commerce Routes */}
|
||||||
<Route path="/dashboard/commerce/store" element={
|
<Route path="/dashboard/commerce/store" element={
|
||||||
<ProtectedRoute>
|
<ProtectedRoute>
|
||||||
@@ -693,6 +740,47 @@ const AppRouter = () => (
|
|||||||
</ProtectedRoute>
|
</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 */}
|
{/* Catch-all route */}
|
||||||
<Route path="*" element={<NotFound />} />
|
<Route path="*" element={<NotFound />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
|
|||||||
@@ -60,7 +60,9 @@ import {
|
|||||||
Server,
|
Server,
|
||||||
ShieldAlert,
|
ShieldAlert,
|
||||||
UserCircle,
|
UserCircle,
|
||||||
Mail
|
Mail,
|
||||||
|
TrendingUp,
|
||||||
|
Instagram
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
|
||||||
const DashboardLayout = ({ children }: { children: React.ReactNode }) => {
|
const DashboardLayout = ({ children }: { children: React.ReactNode }) => {
|
||||||
@@ -87,7 +89,15 @@ const DashboardLayout = ({ children }: { children: React.ReactNode }) => {
|
|||||||
path: '/dashboard/admin',
|
path: '/dashboard/admin',
|
||||||
subItems: [
|
subItems: [
|
||||||
{ icon: BarChart3, label: 'Resumen General', path: '/dashboard/admin?tab=overview' },
|
{ 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: MapPin, label: 'Proveedores', path: '/dashboard/admin?tab=services' },
|
||||||
{ icon: DollarSign, label: 'Financiero', path: '/dashboard/admin?tab=financial' },
|
{ 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: 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,
|
icon: Store,
|
||||||
label: t('commerce'),
|
label: t('commerce'),
|
||||||
@@ -260,8 +282,8 @@ const DashboardLayout = ({ children }: { children: React.ReactNode }) => {
|
|||||||
<div className="decoration blur-2"></div>
|
<div className="decoration blur-2"></div>
|
||||||
<div className="decoration blur-3"></div>
|
<div className="decoration blur-3"></div>
|
||||||
|
|
||||||
{/* Sidebar */}
|
{/* 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'}`} style={{ minWidth: sidebarCollapsed ? '64px' : '320px', maxWidth: sidebarCollapsed ? '64px' : '320px', backdropFilter: 'blur(15px)', backgroundColor: 'rgba(255, 255, 255, 0.9)' }}>
|
<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 */}
|
{/* Sidebar Header */}
|
||||||
<div className="flex items-center justify-between p-6 h-20 border-b border-gray-100">
|
<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' : ''}`}>
|
<Link to="/dashboard" className={`flex items-center space-x-2 ${sidebarCollapsed ? 'justify-center' : ''}`}>
|
||||||
@@ -500,87 +522,91 @@ const DashboardLayout = ({ children }: { children: React.ReactNode }) => {
|
|||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
{/* Main Content Area */}
|
{/* Main Content Area - Responsive */}
|
||||||
<div className={`transition-all duration-300 ${sidebarCollapsed ? 'ml-16' : 'ml-80'}`}>
|
<div className={`transition-all duration-300 ${sidebarCollapsed ? 'md:ml-16 ml-0' : 'md:ml-80 ml-0'}`}>
|
||||||
{/* Top Navigation */}
|
{/* Top Navigation - Responsive */}
|
||||||
<nav className="h-20 px-6 py-4 z-10 relative" style={{ backgroundColor: 'rgba(255, 255, 255, 0.7)', backdropFilter: 'blur(15px)' }}>
|
<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">
|
<div className="flex items-center justify-between">
|
||||||
{/* Left Side */}
|
{/* Left Side - Responsive */}
|
||||||
<div className="flex items-center space-x-4">
|
<div className="flex items-center space-x-2 md:space-x-4">
|
||||||
<button
|
<button
|
||||||
onClick={() => setSidebarCollapsed(!sidebarCollapsed)}
|
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' }}
|
style={{ backgroundColor: '#F84525' }}
|
||||||
>
|
>
|
||||||
<Menu className="w-5 h-5" />
|
<Menu className="w-4 h-4 md:w-5 md:h-5" />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Search */}
|
{/* Search - Hidden on mobile */}
|
||||||
<div className="relative">
|
<div className="relative hidden md:block">
|
||||||
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
<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' }} />
|
<Search className="h-4 w-4" style={{ color: '#69534f' }} />
|
||||||
</div>
|
</div>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder={`${t('search')} (Ctrl+/)`}
|
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={{
|
style={{
|
||||||
backgroundColor: '#fff',
|
backgroundColor: '#fff',
|
||||||
borderColor: '#fff',
|
borderColor: '#fff',
|
||||||
height: '48px',
|
height: '44px',
|
||||||
borderRadius: '0.8rem'
|
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+/)
|
(Ctrl+/)
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Right Side */}
|
{/* Right Side - Responsive */}
|
||||||
<div className="flex items-center space-x-4">
|
<div className="flex items-center space-x-2 md:space-x-4">
|
||||||
{/* Language Selector */}
|
{/* Language Selector - Hidden on small mobile */}
|
||||||
|
<div className="hidden sm:block">
|
||||||
<LanguageSelector />
|
<LanguageSelector />
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Currency Selector */}
|
{/* Currency Selector - Hidden on small mobile */}
|
||||||
|
<div className="hidden sm:block">
|
||||||
<CurrencySelector />
|
<CurrencySelector />
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Refresh (Admin) */}
|
{/* Refresh (Admin) - Hidden on mobile */}
|
||||||
<button
|
<button
|
||||||
onClick={() => window.dispatchEvent(new CustomEvent('admin:refresh'))}
|
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' }}
|
style={{ backgroundColor: '#21272f', borderColor: '#21272f', color: '#F84525' }}
|
||||||
title="Actualizar datos"
|
title="Actualizar datos"
|
||||||
>
|
>
|
||||||
<RefreshCw className="w-5 h-5" />
|
<RefreshCw className="w-4 h-4 md:w-5 md:h-5" />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Notifications */}
|
{/* Notifications */}
|
||||||
<button className="p-2 rounded-xl transition-colors relative" style={{ backgroundColor: '#21272f', borderColor: '#21272f', color: '#F84525' }} title="Notificaciones">
|
<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-5 h-5" />
|
<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>
|
<span className="absolute -top-1 -right-1 bg-red-500 text-white text-[10px] px-1 py-0.5 rounded-full">!</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Theme Toggle */}
|
{/* Theme Toggle - Hidden on mobile */}
|
||||||
<button className="p-2 rounded-xl transition-colors" style={{ backgroundColor: '#21272f', borderColor: '#21272f', color: '#F84525' }}>
|
<button className="hidden md:block p-2 rounded-xl transition-colors" style={{ backgroundColor: '#21272f', borderColor: '#21272f', color: '#F84525' }}>
|
||||||
<Sun className="w-5 h-5" />
|
<Sun className="w-5 h-5" />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Fullscreen */}
|
{/* Fullscreen - Hidden on mobile */}
|
||||||
<button className="p-2 rounded-xl transition-colors" style={{ backgroundColor: '#21272f', borderColor: '#21272f', color: '#F84525' }}>
|
<button className="hidden lg:block p-2 rounded-xl transition-colors" style={{ backgroundColor: '#21272f', borderColor: '#21272f', color: '#F84525' }}>
|
||||||
<Maximize className="w-5 h-5" />
|
<Maximize className="w-5 h-5" />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* User Profile */}
|
{/* User Profile - Responsive */}
|
||||||
<div className="flex items-center space-x-3 pl-4">
|
<div className="flex items-center space-x-2 md:space-x-3 pl-2 md:pl-4">
|
||||||
<div className="flex items-center space-x-3">
|
<div className="flex items-center space-x-2 md: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">
|
<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-sm">
|
<span className="text-white font-semibold text-xs md:text-sm">
|
||||||
{user?.name?.[0] || user?.email?.[0] || 'U'}
|
{user?.name?.[0] || user?.email?.[0] || 'U'}
|
||||||
</span>
|
</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>
|
||||||
<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">
|
<div className="font-semibold text-gray-800 text-sm flex items-center space-x-2">
|
||||||
<span>{user?.name || 'Usuario'}</span>
|
<span>{user?.name || 'Usuario'}</span>
|
||||||
{user?.role === 'super_admin' && (
|
{user?.role === 'super_admin' && (
|
||||||
@@ -597,8 +623,8 @@ const DashboardLayout = ({ children }: { children: React.ReactNode }) => {
|
|||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
{/* Page Content */}
|
{/* Page Content - Responsive padding */}
|
||||||
<main className="p-6">
|
<main className="p-3 md:p-6">
|
||||||
{children}
|
{children}
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -41,11 +41,11 @@ const ExploreSection = () => {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Carousel Container */}
|
{/* Carousel Container - Fully responsive grid */}
|
||||||
<div className="owl-carousel-container relative">
|
<div className="owl-carousel-container relative">
|
||||||
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-6 overflow-x-auto pb-4">
|
<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 */}
|
{/* Region Card 1 - Responsive height */}
|
||||||
<div className="region-card rounded-xl overflow-hidden relative text-white min-w-[300px] h-[458px] group cursor-pointer">
|
<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">
|
<div className="region-card-image h-full">
|
||||||
<img
|
<img
|
||||||
src="https://images.visitarepublicadominicana.org/Punta-Cana-Republica-Dominicana.jpg"
|
src="https://images.visitarepublicadominicana.org/Punta-Cana-Republica-Dominicana.jpg"
|
||||||
|
|||||||
409
src/components/InfluencerMarketplace.tsx
Normal file
409
src/components/InfluencerMarketplace.tsx
Normal 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;
|
||||||
@@ -58,7 +58,7 @@ const PlacesSection = () => {
|
|||||||
return (
|
return (
|
||||||
<section className="py-20 bg-gray-50 relative overflow-hidden">
|
<section className="py-20 bg-gray-50 relative overflow-hidden">
|
||||||
<div className="max-w-7xl mx-auto px-4 py-4">
|
<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 */}
|
{/* Sidebar */}
|
||||||
<div className="lg:col-span-4 sidebar">
|
<div className="lg:col-span-4 sidebar">
|
||||||
<div className="text-center lg:text-left mb-12">
|
<div className="text-center lg:text-left mb-12">
|
||||||
@@ -97,7 +97,7 @@ const PlacesSection = () => {
|
|||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{places.map((place, index) => (
|
{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">
|
<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 */}
|
{/* Image */}
|
||||||
<div className="md:col-span-5 relative bg-white">
|
<div className="md:col-span-5 relative bg-white">
|
||||||
<div className="overflow-hidden relative h-64 md:h-full">
|
<div className="overflow-hidden relative h-64 md:h-full">
|
||||||
@@ -118,8 +118,8 @@ const PlacesSection = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Content */}
|
{/* Content - Responsive padding */}
|
||||||
<div className="md:col-span-7 p-6 flex flex-col">
|
<div className="md:col-span-7 p-4 md:p-6 flex flex-col">
|
||||||
{/* Action buttons */}
|
{/* Action buttons */}
|
||||||
<div className="flex gap-2 justify-end mb-4">
|
<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">
|
<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">
|
||||||
|
|||||||
228
src/components/roles/RoleManagement.tsx
Normal file
228
src/components/roles/RoleManagement.tsx
Normal 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;
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
// API Configuration and Constants
|
// API Configuration and Constants
|
||||||
export const API_CONFIG = {
|
export const API_CONFIG = {
|
||||||
BASE_URL: 'https://karibeo.lesoluciones.net:8443/api/v1',
|
BASE_URL: 'https://api.karibeo.ai:8443/api/v1',
|
||||||
ENDPOINTS: {
|
ENDPOINTS: {
|
||||||
// Authentication
|
// Authentication
|
||||||
LOGIN: '/auth/login',
|
LOGIN: '/auth/login',
|
||||||
@@ -43,6 +43,37 @@ export const API_CONFIG = {
|
|||||||
HOTEL_ROOM_SERVICE: '/hotel/room-service',
|
HOTEL_ROOM_SERVICE: '/hotel/room-service',
|
||||||
HOTEL_STATS: '/hotel/establishments/:id/stats',
|
HOTEL_STATS: '/hotel/establishments/:id/stats',
|
||||||
HOTEL_HOUSEKEEPING: '/hotel/establishments/:id/housekeeping',
|
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
|
// External Assets
|
||||||
|
|||||||
@@ -72,78 +72,7 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children
|
|||||||
const login = async (email: string, password: string) => {
|
const login = async (email: string, password: string) => {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
try {
|
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() };
|
const loginData = { email: email.trim(), password: password.trim() };
|
||||||
console.log('Sending login data (form first):', { email: loginData.email, password: '***' });
|
|
||||||
|
|
||||||
let loginRes: any;
|
let loginRes: any;
|
||||||
// Try application/x-www-form-urlencoded first (some backends validate this path)
|
// Try application/x-www-form-urlencoded first (some backends validate this path)
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ export const useAdminData = () => {
|
|||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
// Check if user has admin permissions
|
|
||||||
const isAdmin = user?.role === 'admin' || user?.role === 'super_admin';
|
const isAdmin = user?.role === 'admin' || user?.role === 'super_admin';
|
||||||
const isSuperAdmin = user?.role === 'super_admin';
|
const isSuperAdmin = user?.role === 'super_admin';
|
||||||
|
|
||||||
|
|||||||
224
src/hooks/useCollections.ts
Normal file
224
src/hooks/useCollections.ts
Normal 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;
|
||||||
@@ -11,7 +11,6 @@ export const useEmergencyData = () => {
|
|||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
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 isOfficer = user?.role === 'politur' || user?.role === 'admin' || user?.role === 'super_admin';
|
||||||
const isAdmin = user?.role === 'admin' || user?.role === 'super_admin';
|
const isAdmin = user?.role === 'admin' || user?.role === 'super_admin';
|
||||||
|
|
||||||
|
|||||||
176
src/hooks/useFavorites.ts
Normal file
176
src/hooks/useFavorites.ts
Normal 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;
|
||||||
177
src/hooks/useNotifications.ts
Normal file
177
src/hooks/useNotifications.ts
Normal 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
211
src/hooks/useQuiz.ts
Normal 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;
|
||||||
75
src/hooks/useResponsive.tsx
Normal file
75
src/hooks/useResponsive.tsx
Normal 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;
|
||||||
|
}
|
||||||
172
src/hooks/useRolesPermissions.ts
Normal file
172
src/hooks/useRolesPermissions.ts
Normal 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
289
src/hooks/useTrips.ts
Normal 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
133
src/lib/validation.ts
Normal 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>;
|
||||||
103
src/middleware/responsiveAPI.ts
Normal file
103
src/middleware/responsiveAPI.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ import { useAuth } from '@/contexts/AuthContext';
|
|||||||
import { useLanguage } from '@/contexts/LanguageContext';
|
import { useLanguage } from '@/contexts/LanguageContext';
|
||||||
import { Apple, Eye, EyeOff } from 'lucide-react';
|
import { Apple, Eye, EyeOff } from 'lucide-react';
|
||||||
import { FaGoogle } from 'react-icons/fa';
|
import { FaGoogle } from 'react-icons/fa';
|
||||||
|
import { loginSchema, type LoginInput } from '@/lib/validation';
|
||||||
|
|
||||||
const SignIn = () => {
|
const SignIn = () => {
|
||||||
const [email, setEmail] = useState('');
|
const [email, setEmail] = useState('');
|
||||||
@@ -24,8 +25,10 @@ const SignIn = () => {
|
|||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
setError('');
|
setError('');
|
||||||
|
|
||||||
|
// SECURITY: Validate inputs before processing
|
||||||
try {
|
try {
|
||||||
await login(email, password);
|
const validatedData = loginSchema.parse({ email, password });
|
||||||
|
await login(validatedData.email, validatedData.password);
|
||||||
// Decide destination based on role
|
// Decide destination based on role
|
||||||
const cached = localStorage.getItem('karibeo-user');
|
const cached = localStorage.getItem('karibeo-user');
|
||||||
const u = user ?? (cached ? JSON.parse(cached) : null);
|
const u = user ?? (cached ? JSON.parse(cached) : null);
|
||||||
@@ -36,7 +39,12 @@ const SignIn = () => {
|
|||||||
navigate('/dashboard');
|
navigate('/dashboard');
|
||||||
}
|
}
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
|
// Handle validation errors from zod
|
||||||
|
if (err.name === 'ZodError') {
|
||||||
|
setError(err.errors[0]?.message || 'Datos inválidos');
|
||||||
|
} else {
|
||||||
setError(err.message);
|
setError(err.message);
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { useAuth } from '@/contexts/AuthContext';
|
|||||||
import { useLanguage } from '@/contexts/LanguageContext';
|
import { useLanguage } from '@/contexts/LanguageContext';
|
||||||
import { Apple, Eye, EyeOff } from 'lucide-react';
|
import { Apple, Eye, EyeOff } from 'lucide-react';
|
||||||
import { FaGoogle } from 'react-icons/fa';
|
import { FaGoogle } from 'react-icons/fa';
|
||||||
|
import { registerSchema, type RegisterInput } from '@/lib/validation';
|
||||||
|
|
||||||
const SignUp = () => {
|
const SignUp = () => {
|
||||||
const [formData, setFormData] = useState({
|
const [formData, setFormData] = useState({
|
||||||
@@ -36,19 +37,17 @@ const SignUp = () => {
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setError('');
|
setError('');
|
||||||
|
|
||||||
if (formData.password !== formData.confirmPassword) {
|
|
||||||
setError('Las contraseñas no coinciden');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!agreeToTerms) {
|
if (!agreeToTerms) {
|
||||||
setError('Debes aceptar los términos de servicio');
|
setError('Debes aceptar los términos de servicio');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SECURITY: Validate all inputs before processing
|
||||||
|
try {
|
||||||
|
const validatedData = registerSchema.parse(formData);
|
||||||
|
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
|
|
||||||
try {
|
|
||||||
await register({
|
await register({
|
||||||
name: formData.fullName,
|
name: formData.fullName,
|
||||||
email: formData.email,
|
email: formData.email,
|
||||||
@@ -59,7 +58,12 @@ const SignUp = () => {
|
|||||||
});
|
});
|
||||||
navigate('/dashboard');
|
navigate('/dashboard');
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
|
// Handle validation errors from zod
|
||||||
|
if (err.name === 'ZodError') {
|
||||||
|
setError(err.errors[0]?.message || 'Datos inválidos');
|
||||||
|
} else {
|
||||||
setError(err.message);
|
setError(err.message);
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import DashboardLayout from '@/components/DashboardLayout';
|
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||||
import { useAnalytics } from '@/hooks/useAnalytics';
|
import { useAnalytics } from '@/hooks/useAnalytics';
|
||||||
@@ -43,7 +42,6 @@ const Analytics = () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DashboardLayout>
|
|
||||||
<div className="p-6 space-y-6">
|
<div className="p-6 space-y-6">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex justify-between items-center">
|
<div className="flex justify-between items-center">
|
||||||
@@ -242,7 +240,6 @@ const Analytics = () => {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</DashboardLayout>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
518
src/pages/dashboard/Collections.tsx
Normal file
518
src/pages/dashboard/Collections.tsx
Normal 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;
|
||||||
340
src/pages/dashboard/Favorites.tsx
Normal file
340
src/pages/dashboard/Favorites.tsx
Normal 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
445
src/pages/dashboard/Quiz.tsx
Normal file
445
src/pages/dashboard/Quiz.tsx
Normal 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;
|
||||||
61
src/pages/dashboard/RolesPermissions.tsx
Normal file
61
src/pages/dashboard/RolesPermissions.tsx
Normal 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;
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import DashboardLayout from '@/components/DashboardLayout';
|
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||||
import { SearchBar } from '@/components/shared/SearchBar';
|
import { SearchBar } from '@/components/shared/SearchBar';
|
||||||
@@ -46,7 +45,6 @@ const SearchEstablishments = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DashboardLayout>
|
|
||||||
<div className="p-6 space-y-6">
|
<div className="p-6 space-y-6">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div>
|
<div>
|
||||||
@@ -205,7 +203,6 @@ const SearchEstablishments = () => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</DashboardLayout>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import DashboardLayout from '@/components/DashboardLayout';
|
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||||
import { SearchBar } from '@/components/shared/SearchBar';
|
import { SearchBar } from '@/components/shared/SearchBar';
|
||||||
@@ -32,7 +31,6 @@ const SearchPlaces = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DashboardLayout>
|
|
||||||
<div className="p-6 space-y-6">
|
<div className="p-6 space-y-6">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div>
|
<div>
|
||||||
@@ -158,7 +156,6 @@ const SearchPlaces = () => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</DashboardLayout>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
636
src/pages/dashboard/Trips.tsx
Normal file
636
src/pages/dashboard/Trips.tsx
Normal 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;
|
||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from '@/components/ui/select';
|
} from '@/components/ui/select';
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||||
import {
|
import {
|
||||||
Mail,
|
Mail,
|
||||||
Plus,
|
Plus,
|
||||||
@@ -32,10 +33,12 @@ import {
|
|||||||
Eye,
|
Eye,
|
||||||
MousePointer,
|
MousePointer,
|
||||||
ShoppingCart,
|
ShoppingCart,
|
||||||
Calendar
|
Calendar,
|
||||||
|
Megaphone
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { useToast } from '@/hooks/use-toast';
|
import { useToast } from '@/hooks/use-toast';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
import InfluencerMarketplace from '@/components/InfluencerMarketplace';
|
||||||
|
|
||||||
const campaignSchema = z.object({
|
const campaignSchema = z.object({
|
||||||
name: z.string().trim().min(1, 'Nombre requerido').max(100, 'Nombre muy largo'),
|
name: z.string().trim().min(1, 'Nombre requerido').max(100, 'Nombre muy largo'),
|
||||||
@@ -152,9 +155,28 @@ const Campaigns = () => {
|
|||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-3xl font-bold text-gray-900">Campañas</h1>
|
<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 marketing</p>
|
<p className="text-gray-600 mt-1">Gestiona tus campañas de email e influencer marketing</p>
|
||||||
</div>
|
</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}>
|
<Dialog open={isCreateDialogOpen} onOpenChange={setIsCreateDialogOpen}>
|
||||||
<DialogTrigger asChild>
|
<DialogTrigger asChild>
|
||||||
<Button>
|
<Button>
|
||||||
@@ -412,6 +434,13 @@ const Campaigns = () => {
|
|||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
{/* Influencers Tab */}
|
||||||
|
<TabsContent value="influencers">
|
||||||
|
<InfluencerMarketplace />
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
562
src/pages/dashboard/influencer/InfluencerDashboard.tsx
Normal file
562
src/pages/dashboard/influencer/InfluencerDashboard.tsx
Normal 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;
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
// API Configuration
|
// 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)
|
// Get auth token from localStorage (support both keys)
|
||||||
const getAuthToken = () => {
|
const getAuthToken = () => {
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
* Integración con la API del sistema de aplicaciones turísticas
|
* 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
|
// Tipos de datos principales
|
||||||
export interface User {
|
export interface User {
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ export interface User {
|
|||||||
avatar: string;
|
avatar: string;
|
||||||
online: boolean;
|
online: boolean;
|
||||||
lastSeen?: string;
|
lastSeen?: string;
|
||||||
|
serviceType?: 'taxi' | 'restaurant' | 'hotel' | 'guide' | 'politur' | 'support';
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Message {
|
export interface Message {
|
||||||
@@ -32,6 +33,7 @@ export interface Chat {
|
|||||||
avatar?: string;
|
avatar?: string;
|
||||||
online?: boolean;
|
online?: boolean;
|
||||||
lastActivity: string;
|
lastActivity: string;
|
||||||
|
serviceType?: 'taxi' | 'restaurant' | 'hotel' | 'guide' | 'politur' | 'support';
|
||||||
}
|
}
|
||||||
|
|
||||||
class ChatApiService {
|
class ChatApiService {
|
||||||
|
|||||||
279
src/services/collectionsApi.ts
Normal file
279
src/services/collectionsApi.ts
Normal 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;
|
||||||
@@ -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';
|
||||||
241
src/services/favoritesApi.ts
Normal file
241
src/services/favoritesApi.ts
Normal 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;
|
||||||
@@ -1,290 +1,277 @@
|
|||||||
// Mock data for chat system
|
// Mock data for chat system - Tourist Services
|
||||||
import { User, Chat, Message } from './chatApi';
|
import { User, Chat, Message } from './chatApi';
|
||||||
|
|
||||||
export const mockChatData = {
|
export const mockChatData = {
|
||||||
users: [
|
users: [
|
||||||
{
|
{
|
||||||
id: 'user-1',
|
id: 'taxi-1',
|
||||||
name: 'Alexander Kaminski',
|
name: 'Taxi Central Punta Cana',
|
||||||
email: 'alexander@example.com',
|
email: 'taxicentral@karibeo.com',
|
||||||
avatar: '/api/placeholder/40/40',
|
avatar: '/api/placeholder/40/40',
|
||||||
online: true,
|
online: true,
|
||||||
lastSeen: new Date().toISOString()
|
lastSeen: new Date().toISOString(),
|
||||||
|
serviceType: 'taxi'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'user-2',
|
id: 'restaurant-1',
|
||||||
name: 'Edwin Martins',
|
name: 'El Mesón Restaurant',
|
||||||
email: 'edwin@example.com',
|
email: 'elmeson@karibeo.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',
|
|
||||||
avatar: '/api/placeholder/40/40',
|
avatar: '/api/placeholder/40/40',
|
||||||
online: true,
|
online: true,
|
||||||
lastSeen: new Date().toISOString()
|
lastSeen: new Date().toISOString(),
|
||||||
|
serviceType: 'restaurant'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'user-4',
|
id: 'hotel-1',
|
||||||
name: 'Ethan Blackwood',
|
name: 'Paradisus Palma Real',
|
||||||
email: 'ethan@example.com',
|
email: 'paradisus@karibeo.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',
|
|
||||||
avatar: '/api/placeholder/40/40',
|
avatar: '/api/placeholder/40/40',
|
||||||
online: true,
|
online: true,
|
||||||
lastSeen: new Date().toISOString()
|
lastSeen: new Date().toISOString(),
|
||||||
|
serviceType: 'hotel'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'user-6',
|
id: 'guide-1',
|
||||||
name: 'Marcus Knight',
|
name: 'Carlos - Guía Turístico',
|
||||||
email: 'marcus@example.com',
|
email: 'carlos.guide@karibeo.com',
|
||||||
avatar: '/api/placeholder/40/40',
|
avatar: '/api/placeholder/40/40',
|
||||||
online: false,
|
online: false,
|
||||||
lastSeen: new Date(Date.now() - 1800000).toISOString()
|
lastSeen: new Date(Date.now() - 3600000).toISOString(),
|
||||||
|
serviceType: 'guide'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'user-7',
|
id: 'politur-1',
|
||||||
name: 'Pranoti Deshpande',
|
name: 'POLITUR Punta Cana',
|
||||||
email: 'pranoti@example.com',
|
email: 'politur.pc@karibeo.com',
|
||||||
avatar: '/api/placeholder/40/40',
|
avatar: '/api/placeholder/40/40',
|
||||||
online: true,
|
online: true,
|
||||||
lastSeen: new Date().toISOString()
|
lastSeen: new Date().toISOString(),
|
||||||
|
serviceType: 'politur'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'user-8',
|
id: 'taxi-2',
|
||||||
name: 'Sarah Wilson',
|
name: 'Uber Bávaro',
|
||||||
email: 'sarah@example.com',
|
email: 'uber.bavaro@karibeo.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',
|
|
||||||
avatar: '/api/placeholder/40/40',
|
avatar: '/api/placeholder/40/40',
|
||||||
online: true,
|
online: true,
|
||||||
lastSeen: new Date().toISOString()
|
lastSeen: new Date().toISOString(),
|
||||||
|
serviceType: 'taxi'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'user-10',
|
id: 'restaurant-2',
|
||||||
name: 'Lisa Rodriguez',
|
name: 'Captain Cook Restaurant',
|
||||||
email: 'lisa@example.com',
|
email: 'captaincook@karibeo.com',
|
||||||
avatar: '/api/placeholder/40/40',
|
avatar: '/api/placeholder/40/40',
|
||||||
online: false,
|
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[],
|
] as User[],
|
||||||
|
|
||||||
chats: [
|
chats: [
|
||||||
{
|
{
|
||||||
id: 'chat-1',
|
id: 'chat-taxi-1',
|
||||||
name: 'Alexander Kaminski',
|
name: 'Taxi Central Punta Cana',
|
||||||
type: 'direct',
|
type: 'direct',
|
||||||
|
serviceType: 'taxi',
|
||||||
participants: [
|
participants: [
|
||||||
{
|
{
|
||||||
id: 'user-1',
|
id: 'taxi-1',
|
||||||
name: 'Alexander Kaminski',
|
name: 'Taxi Central Punta Cana',
|
||||||
email: 'alexander@example.com',
|
email: 'taxicentral@karibeo.com',
|
||||||
avatar: '/api/placeholder/40/40',
|
avatar: '/api/placeholder/40/40',
|
||||||
online: true
|
online: true
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
unreadCount: 2,
|
unreadCount: 1,
|
||||||
lastActivity: new Date(Date.now() - 300000).toISOString(),
|
lastActivity: new Date(Date.now() - 300000).toISOString(),
|
||||||
avatar: '/api/placeholder/40/40',
|
avatar: '/api/placeholder/40/40',
|
||||||
online: true,
|
online: true,
|
||||||
lastMessage: {
|
lastMessage: {
|
||||||
id: 'msg-1',
|
id: 'msg-taxi-1',
|
||||||
chatId: 'chat-1',
|
chatId: 'chat-taxi-1',
|
||||||
senderId: 'user-1',
|
senderId: 'taxi-1',
|
||||||
senderName: 'Alexander Kaminski',
|
senderName: 'Taxi Central Punta Cana',
|
||||||
senderAvatar: '/api/placeholder/40/40',
|
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',
|
timestamp: '10:30 AM',
|
||||||
isOwn: false,
|
isOwn: false,
|
||||||
status: 'delivered'
|
status: 'delivered'
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'chat-2',
|
id: 'chat-restaurant-1',
|
||||||
name: 'Edwin Martins',
|
name: 'El Mesón Restaurant',
|
||||||
type: 'direct',
|
type: 'direct',
|
||||||
|
serviceType: 'restaurant',
|
||||||
participants: [
|
participants: [
|
||||||
{
|
{
|
||||||
id: 'user-2',
|
id: 'restaurant-1',
|
||||||
name: 'Edwin Martins',
|
name: 'El Mesón Restaurant',
|
||||||
email: 'edwin@example.com',
|
email: 'elmeson@karibeo.com',
|
||||||
avatar: '/api/placeholder/40/40',
|
avatar: '/api/placeholder/40/40',
|
||||||
online: false
|
online: true
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
unreadCount: 1,
|
unreadCount: 0,
|
||||||
lastActivity: new Date(Date.now() - 3600000).toISOString(),
|
lastActivity: new Date(Date.now() - 3600000).toISOString(),
|
||||||
avatar: '/api/placeholder/40/40',
|
avatar: '/api/placeholder/40/40',
|
||||||
online: false,
|
online: true,
|
||||||
lastMessage: {
|
lastMessage: {
|
||||||
id: 'msg-2',
|
id: 'msg-restaurant-1',
|
||||||
chatId: 'chat-2',
|
chatId: 'chat-restaurant-1',
|
||||||
senderId: 'user-2',
|
senderId: 'restaurant-1',
|
||||||
senderName: 'Edwin Martins',
|
senderName: 'El Mesón Restaurant',
|
||||||
senderAvatar: '/api/placeholder/40/40',
|
senderAvatar: '/api/placeholder/40/40',
|
||||||
content: 'How can i improve my chances of getting a deposit?',
|
content: 'Su reserva para 4 personas está confirmada para las 8:00 PM',
|
||||||
timestamp: '10:05 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,
|
isOwn: false,
|
||||||
status: 'delivered'
|
status: 'delivered'
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'chat-3',
|
id: 'chat-guide-1',
|
||||||
name: 'Gabriel North',
|
name: 'Carlos - Guía Turístico',
|
||||||
type: 'direct',
|
type: 'direct',
|
||||||
|
serviceType: 'guide',
|
||||||
participants: [
|
participants: [
|
||||||
{
|
{
|
||||||
id: 'user-3',
|
id: 'guide-1',
|
||||||
name: 'Gabriel North',
|
name: 'Carlos - Guía Turístico',
|
||||||
email: 'gabriel@example.com',
|
email: 'carlos.guide@karibeo.com',
|
||||||
avatar: '/api/placeholder/40/40',
|
avatar: '/api/placeholder/40/40',
|
||||||
online: true
|
online: false
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
unreadCount: 0,
|
unreadCount: 0,
|
||||||
lastActivity: new Date(Date.now() - 86400000).toISOString(),
|
lastActivity: new Date(Date.now() - 86400000).toISOString(),
|
||||||
avatar: '/api/placeholder/40/40',
|
avatar: '/api/placeholder/40/40',
|
||||||
online: true,
|
online: false,
|
||||||
lastMessage: {
|
lastMessage: {
|
||||||
id: 'msg-3',
|
id: 'msg-guide-1',
|
||||||
chatId: 'chat-3',
|
chatId: 'chat-guide-1',
|
||||||
senderId: 'user-3',
|
senderId: 'guide-1',
|
||||||
senderName: 'Gabriel North',
|
senderName: 'Carlos - Guía Turístico',
|
||||||
senderAvatar: '/api/placeholder/40/40',
|
senderAvatar: '/api/placeholder/40/40',
|
||||||
content: 'Hey Chris, could i ask you to help me out with variation...',
|
content: 'Tour a Saona Island confirmado para mañana a las 8 AM. Incluye snorkel y almuerzo.',
|
||||||
timestamp: 'Tue',
|
timestamp: 'Ayer',
|
||||||
isOwn: false,
|
isOwn: false,
|
||||||
status: 'read'
|
status: 'read'
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'chat-4',
|
id: 'chat-politur-1',
|
||||||
name: 'Ethan Blackwood',
|
name: 'POLITUR Punta Cana',
|
||||||
type: 'direct',
|
type: 'direct',
|
||||||
|
serviceType: 'politur',
|
||||||
participants: [
|
participants: [
|
||||||
{
|
{
|
||||||
id: 'user-4',
|
id: 'politur-1',
|
||||||
name: 'Ethan Blackwood',
|
name: 'POLITUR Punta Cana',
|
||||||
email: 'ethan@example.com',
|
email: 'politur.pc@karibeo.com',
|
||||||
avatar: '/api/placeholder/40/40',
|
avatar: '/api/placeholder/40/40',
|
||||||
online: false
|
online: true
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
unreadCount: 0,
|
unreadCount: 0,
|
||||||
lastActivity: new Date(Date.now() - 172800000).toISOString(),
|
lastActivity: new Date(Date.now() - 172800000).toISOString(),
|
||||||
avatar: '/api/placeholder/40/40',
|
avatar: '/api/placeholder/40/40',
|
||||||
online: false,
|
online: true,
|
||||||
lastMessage: {
|
lastMessage: {
|
||||||
id: 'msg-4',
|
id: 'msg-politur-1',
|
||||||
chatId: 'chat-4',
|
chatId: 'chat-politur-1',
|
||||||
senderId: 'user-4',
|
senderId: 'politur-1',
|
||||||
senderName: 'Ethan Blackwood',
|
senderName: 'POLITUR Punta Cana',
|
||||||
senderAvatar: '/api/placeholder/40/40',
|
senderAvatar: '/api/placeholder/40/40',
|
||||||
content: 'By injected humour, or randomised words which...',
|
content: 'Estamos disponibles 24/7 para asistencia turística. ¿En qué podemos ayudarle?',
|
||||||
timestamp: '1/22/2019',
|
timestamp: 'Mar 5',
|
||||||
isOwn: false,
|
isOwn: false,
|
||||||
status: 'read'
|
status: 'read'
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'chat-5',
|
id: 'chat-support',
|
||||||
name: 'Alexander Steele',
|
name: 'Soporte Karibeo',
|
||||||
type: 'direct',
|
type: 'direct',
|
||||||
|
serviceType: 'support',
|
||||||
participants: [
|
participants: [
|
||||||
{
|
{
|
||||||
id: 'user-5',
|
id: 'admin-support',
|
||||||
name: 'Alexander Steele',
|
name: 'Soporte Karibeo',
|
||||||
email: 'alexs@example.com',
|
email: 'support@karibeo.com',
|
||||||
avatar: '/api/placeholder/40/40',
|
avatar: '/api/placeholder/40/40',
|
||||||
online: true
|
online: true
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
unreadCount: 3,
|
unreadCount: 0,
|
||||||
lastActivity: new Date(Date.now() - 259200000).toISOString(),
|
lastActivity: new Date(Date.now() - 259200000).toISOString(),
|
||||||
avatar: '/api/placeholder/40/40',
|
avatar: '/api/placeholder/40/40',
|
||||||
online: true,
|
online: true,
|
||||||
lastMessage: {
|
lastMessage: {
|
||||||
id: 'msg-5',
|
id: 'msg-support-1',
|
||||||
chatId: 'chat-5',
|
chatId: 'chat-support',
|
||||||
senderId: 'user-5',
|
senderId: 'admin-support',
|
||||||
senderName: 'Alexander Steele',
|
senderName: 'Soporte Karibeo',
|
||||||
senderAvatar: '/api/placeholder/40/40',
|
senderAvatar: '/api/placeholder/40/40',
|
||||||
content: 'No more running out of the office at 4pm on Fridays!',
|
content: 'Bienvenido a Karibeo. ¿En qué podemos ayudarle hoy?',
|
||||||
timestamp: '1/18/2019',
|
timestamp: 'Mar 3',
|
||||||
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',
|
|
||||||
isOwn: false,
|
isOwn: false,
|
||||||
status: 'read'
|
status: 'read'
|
||||||
}
|
}
|
||||||
@@ -292,96 +279,152 @@ export const mockChatData = {
|
|||||||
] as Chat[],
|
] as Chat[],
|
||||||
|
|
||||||
messages: [
|
messages: [
|
||||||
// Chat 1 messages
|
// Taxi Chat messages
|
||||||
{
|
{
|
||||||
id: 'msg-1-1',
|
id: 'msg-taxi-1-1',
|
||||||
chatId: 'chat-1',
|
chatId: 'chat-taxi-1',
|
||||||
senderId: 'user-1',
|
senderId: 'current-user',
|
||||||
senderName: 'Alexander Kaminski',
|
senderName: 'You',
|
||||||
senderAvatar: '/api/placeholder/40/40',
|
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',
|
timestamp: '10:30 AM',
|
||||||
isOwn: false,
|
isOwn: false,
|
||||||
status: 'delivered'
|
status: 'delivered'
|
||||||
},
|
},
|
||||||
|
// Restaurant Chat messages
|
||||||
{
|
{
|
||||||
id: 'msg-1-2',
|
id: 'msg-restaurant-1-1',
|
||||||
chatId: 'chat-1',
|
chatId: 'chat-restaurant-1',
|
||||||
senderId: 'current-user',
|
senderId: 'current-user',
|
||||||
senderName: 'You',
|
senderName: 'You',
|
||||||
senderAvatar: '/api/placeholder/40/40',
|
senderAvatar: '/api/placeholder/40/40',
|
||||||
content: "Hi! I'm doing great, thank you for asking. How about you?",
|
content: 'Hola, quisiera hacer una reserva para esta noche',
|
||||||
timestamp: '10:35 AM',
|
timestamp: '9:00 AM',
|
||||||
isOwn: true,
|
isOwn: true,
|
||||||
status: 'read'
|
status: 'read'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'msg-1-3',
|
id: 'msg-restaurant-1-2',
|
||||||
chatId: 'chat-1',
|
chatId: 'chat-restaurant-1',
|
||||||
senderId: 'user-1',
|
senderId: 'restaurant-1',
|
||||||
senderName: 'Alexander Kaminski',
|
senderName: 'El Mesón Restaurant',
|
||||||
senderAvatar: '/api/placeholder/40/40',
|
senderAvatar: '/api/placeholder/40/40',
|
||||||
content: 'A new feature has been updated to your account. Would you like me to show you around?',
|
content: '¡Hola! Claro que sí. ¿Para cuántas personas y a qué hora?',
|
||||||
timestamp: '10:36 AM',
|
timestamp: '9:10 AM',
|
||||||
isOwn: false,
|
isOwn: false,
|
||||||
status: 'delivered'
|
status: 'delivered'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'msg-1-4',
|
id: 'msg-restaurant-1-3',
|
||||||
chatId: 'chat-1',
|
chatId: 'chat-restaurant-1',
|
||||||
senderId: 'current-user',
|
senderId: 'current-user',
|
||||||
senderName: 'You',
|
senderName: 'You',
|
||||||
senderAvatar: '/api/placeholder/40/40',
|
senderAvatar: '/api/placeholder/40/40',
|
||||||
content: "That sounds great! I'd love to hear more about it.",
|
content: 'Para 4 personas a las 8:00 PM',
|
||||||
timestamp: '10:40 AM',
|
timestamp: '9:12 AM',
|
||||||
isOwn: true,
|
isOwn: true,
|
||||||
status: 'read'
|
status: 'read'
|
||||||
},
|
},
|
||||||
// Chat 2 messages
|
|
||||||
{
|
{
|
||||||
id: 'msg-2-1',
|
id: 'msg-restaurant-1-4',
|
||||||
chatId: 'chat-2',
|
chatId: 'chat-restaurant-1',
|
||||||
senderId: 'user-2',
|
senderId: 'restaurant-1',
|
||||||
senderName: 'Edwin Martins',
|
senderName: 'El Mesón Restaurant',
|
||||||
senderAvatar: '/api/placeholder/40/40',
|
senderAvatar: '/api/placeholder/40/40',
|
||||||
content: 'How can i improve my chances of getting a deposit?',
|
content: 'Su reserva para 4 personas está confirmada para las 8:00 PM. ¿Tiene alguna preferencia dietética?',
|
||||||
timestamp: '10:05 PM',
|
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,
|
isOwn: false,
|
||||||
status: 'delivered'
|
status: 'delivered'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'msg-2-2',
|
id: 'msg-hotel-1-2',
|
||||||
chatId: 'chat-2',
|
chatId: 'chat-hotel-1',
|
||||||
senderId: 'current-user',
|
senderId: 'current-user',
|
||||||
senderName: 'You',
|
senderName: 'You',
|
||||||
senderAvatar: '/api/placeholder/40/40',
|
senderAvatar: '/api/placeholder/40/40',
|
||||||
content: 'There are several ways to improve your chances. First, make sure your profile is complete...',
|
content: 'Gracias. ¿A qué hora es el check-in?',
|
||||||
timestamp: '10:10 PM',
|
timestamp: '10:03 AM',
|
||||||
isOwn: true,
|
isOwn: true,
|
||||||
status: 'read'
|
status: 'read'
|
||||||
},
|
},
|
||||||
// Chat 3 messages
|
|
||||||
{
|
{
|
||||||
id: 'msg-3-1',
|
id: 'msg-hotel-1-3',
|
||||||
chatId: 'chat-3',
|
chatId: 'chat-hotel-1',
|
||||||
senderId: 'user-3',
|
senderId: 'hotel-1',
|
||||||
senderName: 'Gabriel North',
|
senderName: 'Paradisus Palma Real',
|
||||||
senderAvatar: '/api/placeholder/40/40',
|
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',
|
timestamp: '2:15 PM',
|
||||||
isOwn: false,
|
isOwn: false,
|
||||||
status: 'read'
|
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[]
|
] as Message[]
|
||||||
};
|
};
|
||||||
244
src/services/notificationsApi.ts
Normal file
244
src/services/notificationsApi.ts
Normal 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
191
src/services/quizApi.ts
Normal 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;
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
const API_BASE_URL = 'https://karibeo.lesoluciones.net:8443/api';
|
import { API_BASE_URL } from './config';
|
||||||
|
|
||||||
export interface Review {
|
export interface Review {
|
||||||
id: string;
|
id: string;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
const API_BASE_URL = 'https://karibeo.lesoluciones.net:8443/api';
|
import { API_BASE_URL } from './config';
|
||||||
|
|
||||||
export interface TourismOffer {
|
export interface TourismOffer {
|
||||||
id: string;
|
id: string;
|
||||||
|
|||||||
408
src/services/tripsApi.ts
Normal file
408
src/services/tripsApi.ts
Normal 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
63
src/types/roles.ts
Normal 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
93
src/utils/responsive.ts
Normal 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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user