Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9667ebc83d | ||
|
|
7289ad6c26 | ||
|
|
c72ef29319 | ||
|
|
cc6ffdc5a5 | ||
|
|
626e3342d0 | ||
|
|
3acad140c5 | ||
|
|
905966d548 | ||
|
|
53928328d4 | ||
|
|
ed2107086c | ||
|
|
c1716b2193 | ||
|
|
447ff6a4d6 | ||
|
|
6ec8855c00 |
@@ -18,8 +18,8 @@ yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# Web
|
||||
web/frontend/dist/
|
||||
web/frontend/.vite/
|
||||
web/dashboard/dist/
|
||||
web/dashboard/.vite/
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
|
||||
4
.gitignore
vendored
@@ -177,5 +177,5 @@ yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# Web build
|
||||
web/frontend/dist/
|
||||
web/frontend/.vite/
|
||||
web/dashboard/dist/
|
||||
web/dashboard/.vite/
|
||||
|
||||
10
DOCKER.md
@@ -31,7 +31,7 @@ docker-compose up -d
|
||||
Esto iniciará:
|
||||
- **Redis** (puerto 6379) - Cache de artículos
|
||||
- **Backend** (puerto 3001) - API Node.js
|
||||
- **Frontend** (puerto 3000) - Interfaz web Vue
|
||||
- **Dashboard** (puerto 3000) - Interfaz web Vue
|
||||
- **Wallabicher Python** - Servicio principal de monitoreo
|
||||
|
||||
### 3. Acceder a la interfaz
|
||||
@@ -51,7 +51,7 @@ Abre tu navegador en: **http://localhost:3000**
|
||||
- **WebSocket**: ws://localhost:3001
|
||||
- **Funciones**: API REST y WebSockets para la interfaz web
|
||||
|
||||
### Frontend (Vue + Nginx)
|
||||
### Dashboard (Vue + Nginx)
|
||||
- **Puerto**: 3000
|
||||
- **URL**: http://localhost:3000
|
||||
- **Funciones**: Interfaz web moderna
|
||||
@@ -70,7 +70,7 @@ docker-compose logs -f
|
||||
# Servicio específico
|
||||
docker-compose logs -f wallabicher
|
||||
docker-compose logs -f backend
|
||||
docker-compose logs -f frontend
|
||||
docker-compose logs -f dashboard
|
||||
```
|
||||
|
||||
### Detener servicios
|
||||
@@ -158,11 +158,11 @@ rm -rf monitor.log
|
||||
mkdir -p logs
|
||||
```
|
||||
|
||||
### El frontend no carga
|
||||
### El dashboard no carga
|
||||
|
||||
Verifica los logs:
|
||||
```bash
|
||||
docker-compose logs frontend
|
||||
docker-compose logs dashboard
|
||||
```
|
||||
|
||||
### Reconstruir todo desde cero
|
||||
|
||||
324
KEEPALIVE_SYSTEM.md
Normal file
@@ -0,0 +1,324 @@
|
||||
# Sistema de Keepalive y Seguimiento de Usuarios Activos
|
||||
|
||||
## 📝 Descripción
|
||||
|
||||
El sistema de keepalive permite rastrear en tiempo real qué usuarios están activos y cuándo fue su última actividad. Utiliza el WebSocket existente para mantener una conexión persistente y enviar heartbeats periódicos.
|
||||
|
||||
## 🎯 Características
|
||||
|
||||
### Backend
|
||||
|
||||
1. **WebSocket con Heartbeat**
|
||||
- Envío automático de ping cada 60 segundos
|
||||
- Detección de conexiones inactivas
|
||||
- Cierre automático de conexiones sin respuesta
|
||||
|
||||
2. **Seguimiento de Actividad**
|
||||
- Registro de última actividad en sesiones
|
||||
- Estado activo/inactivo por usuario
|
||||
- Timeout de inactividad: 5 minutos
|
||||
|
||||
3. **Base de Datos**
|
||||
- Campo `lastActivity` en sesiones
|
||||
- Campo `isActive` para marcar usuarios conectados
|
||||
- Índices optimizados para consultas rápidas
|
||||
|
||||
4. **Endpoints**
|
||||
- `GET /api/users/active` - Obtener usuarios activos en tiempo real
|
||||
- `GET /api/admin/sessions` - Obtener todas las sesiones con estado de actividad
|
||||
- Ambos requieren autenticación (el segundo requiere rol admin)
|
||||
|
||||
### Frontend
|
||||
|
||||
1. **Heartbeat Automático**
|
||||
- Envío de heartbeat cada 30 segundos
|
||||
- Respuesta automática a pings del servidor
|
||||
|
||||
2. **Detección de Actividad**
|
||||
- Seguimiento de clicks, teclas, scroll y movimientos del ratón
|
||||
- Throttling de 10 segundos para no sobrecargar el servidor
|
||||
- Envío automático de eventos de actividad
|
||||
|
||||
3. **Componente de Usuarios Activos**
|
||||
- Lista de usuarios conectados en tiempo real
|
||||
- Indicadores de estado (activo/inactivo)
|
||||
- Actualización automática cada 30 segundos
|
||||
- Actualización en tiempo real vía WebSocket
|
||||
|
||||
## 🚀 Uso
|
||||
|
||||
### Ver Usuarios Activos
|
||||
|
||||
Los administradores pueden ver los usuarios activos en:
|
||||
- **Dashboard principal** - Componente "Usuarios Activos" en la parte superior
|
||||
- **Vista de Sesiones** - `/sessions` - Muestra todas las sesiones con estado de conexión
|
||||
- **Endpoint API**: `GET /api/users/active`
|
||||
|
||||
### Ver Estado de Sesiones
|
||||
|
||||
La vista de **Sesiones** (`/sessions`) ahora muestra información detallada sobre el estado de cada sesión:
|
||||
|
||||
**Estadísticas mejoradas:**
|
||||
- Total de sesiones
|
||||
- Sesiones válidas (no expiradas)
|
||||
- 🟢 Sesiones conectadas (con actividad reciente < 5 min)
|
||||
- 🟡 Sesiones inactivas (válidas pero sin actividad)
|
||||
- 🔴 Sesiones expiradas
|
||||
- Usuarios únicos
|
||||
|
||||
**Por cada sesión:**
|
||||
- Estado de validez: ✅ Válida / 🔴 Expirada
|
||||
- Estado de conexión: 🟢 Conectado / 🟡 Inactivo
|
||||
- Última actividad (relativa y absoluta)
|
||||
- Información del dispositivo
|
||||
- Token y fechas de creación/expiración
|
||||
|
||||
### Estructura de Respuesta
|
||||
|
||||
**GET /api/users/active:**
|
||||
```json
|
||||
{
|
||||
"activeUsers": [
|
||||
{
|
||||
"username": "admin",
|
||||
"role": "admin",
|
||||
"status": "active",
|
||||
"lastActivity": "2026-01-21T10:30:00.000Z",
|
||||
"connectedViaWebSocket": true
|
||||
},
|
||||
{
|
||||
"username": "user1",
|
||||
"role": "user",
|
||||
"status": "inactive",
|
||||
"lastActivity": "2026-01-21T10:25:00.000Z",
|
||||
"connectedViaWebSocket": false,
|
||||
"deviceInfo": {
|
||||
"browser": "Chrome",
|
||||
"os": "Windows"
|
||||
}
|
||||
}
|
||||
],
|
||||
"total": 2,
|
||||
"timestamp": "2026-01-21T10:35:00.000Z"
|
||||
}
|
||||
```
|
||||
|
||||
**GET /api/admin/sessions:**
|
||||
```json
|
||||
{
|
||||
"sessions": [
|
||||
{
|
||||
"token": "abc123...",
|
||||
"username": "admin",
|
||||
"fingerprint": "xyz...",
|
||||
"deviceInfo": {
|
||||
"browser": "Chrome",
|
||||
"browserVersion": "120.0",
|
||||
"os": "Linux",
|
||||
"ip": "192.168.1.100"
|
||||
},
|
||||
"createdAt": "2026-01-21T09:00:00.000Z",
|
||||
"expiresAt": "2026-01-22T09:00:00.000Z",
|
||||
"lastActivity": "2026-01-21T10:30:00.000Z",
|
||||
"isActive": true,
|
||||
"isExpired": false
|
||||
}
|
||||
],
|
||||
"stats": {
|
||||
"total": 10,
|
||||
"active": 8,
|
||||
"expired": 2,
|
||||
"connected": 5,
|
||||
"inactive": 3,
|
||||
"byUser": {
|
||||
"admin": 3,
|
||||
"user1": 2
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 🔧 Configuración
|
||||
|
||||
### Timeouts y Intervalos
|
||||
|
||||
**Backend** (`web/backend/services/websocket.js`):
|
||||
```javascript
|
||||
// Tiempo de inactividad para marcar como inactivo (5 minutos)
|
||||
const INACTIVE_TIMEOUT = 5 * 60 * 1000;
|
||||
|
||||
// Intervalo para limpiar conexiones inactivas (1 minuto)
|
||||
const CLEANUP_INTERVAL = 60 * 1000;
|
||||
```
|
||||
|
||||
**Frontend** (`web/dashboard/src/App.vue`):
|
||||
```javascript
|
||||
// Heartbeat cada 30 segundos
|
||||
heartbeatInterval = setInterval(() => { ... }, 30000);
|
||||
|
||||
// Throttle de actividad: 10 segundos
|
||||
activityThrottleTimeout = setTimeout(() => { ... }, 10000);
|
||||
```
|
||||
|
||||
## 📊 Estados de Usuario
|
||||
|
||||
1. **Active** (Activo)
|
||||
- Usuario conectado vía WebSocket
|
||||
- Actividad reciente (< 5 minutos)
|
||||
- Indicador verde
|
||||
|
||||
2. **Inactive** (Inactivo)
|
||||
- Usuario conectado pero sin actividad reciente (> 5 minutos)
|
||||
- Indicador amarillo
|
||||
|
||||
3. **Offline** (Desconectado)
|
||||
- Sin conexión WebSocket
|
||||
- No aparece en la lista de usuarios activos
|
||||
|
||||
## 🔄 Flujo de Datos
|
||||
|
||||
### Conexión WebSocket
|
||||
|
||||
```
|
||||
Cliente conecta → Servidor valida token → Actualiza lastActivity
|
||||
↓
|
||||
Servidor envía confirmación de conexión
|
||||
↓
|
||||
Cliente inicia heartbeat cada 30s
|
||||
↓
|
||||
Servidor responde con pong
|
||||
↓
|
||||
Se actualiza lastActivity en DB
|
||||
```
|
||||
|
||||
### Eventos de Usuario
|
||||
|
||||
```
|
||||
Usuario interactúa (click, tecla, etc.)
|
||||
↓
|
||||
Cliente envía evento de actividad (throttled)
|
||||
↓
|
||||
Servidor actualiza lastActivity
|
||||
↓
|
||||
Broadcast de cambio de estado a otros usuarios
|
||||
```
|
||||
|
||||
### Desconexión
|
||||
|
||||
```
|
||||
Cliente cierra conexión o timeout
|
||||
↓
|
||||
Servidor marca isActive = false en DB
|
||||
↓
|
||||
Broadcast de estado offline a otros usuarios
|
||||
```
|
||||
|
||||
## 🎨 Componente ActiveUsers
|
||||
|
||||
Ubicación: `web/dashboard/src/components/ActiveUsers.vue`
|
||||
|
||||
### Props
|
||||
|
||||
Ninguna (componente standalone)
|
||||
|
||||
### Eventos Escuchados
|
||||
|
||||
- `user-status-change` - Cambios de estado de usuarios vía WebSocket
|
||||
|
||||
### Características
|
||||
|
||||
- **Actualización automática**: cada 30 segundos
|
||||
- **Actualización en tiempo real**: vía eventos WebSocket
|
||||
- **Indicadores visuales**:
|
||||
- 🟢 Verde: usuario activo
|
||||
- 🟡 Amarillo: usuario inactivo
|
||||
- 📶 Icono de señal: conectado vía WebSocket
|
||||
- **Badges de rol**: admin/user
|
||||
- **Timestamp relativo**: "hace X minutos"
|
||||
|
||||
## 🔐 Seguridad
|
||||
|
||||
- Solo usuarios autenticados pueden ver usuarios activos
|
||||
- El token se valida en cada conexión WebSocket
|
||||
- Las sesiones expiran automáticamente según configuración
|
||||
- Los heartbeats mantienen la sesión activa
|
||||
|
||||
## 📈 Escalabilidad
|
||||
|
||||
El sistema está diseñado para manejar múltiples usuarios concurrentes:
|
||||
|
||||
1. **Throttling de actividad**: evita spam de eventos
|
||||
2. **Índices en MongoDB**: consultas optimizadas
|
||||
3. **Limpieza periódica**: cierre de conexiones inactivas
|
||||
4. **TTL en sesiones**: eliminación automática de sesiones expiradas
|
||||
|
||||
## 🐛 Debugging
|
||||
|
||||
Para ver logs de WebSocket en la consola del navegador:
|
||||
|
||||
```javascript
|
||||
// En la consola del navegador
|
||||
localStorage.setItem('debug', 'websocket');
|
||||
```
|
||||
|
||||
Logs del servidor:
|
||||
```
|
||||
Cliente WebSocket conectado: username (role)
|
||||
Cliente WebSocket desconectado: username
|
||||
Usuario inactivo detectado: username
|
||||
Cerrando conexión inactiva: username
|
||||
```
|
||||
|
||||
## 🖥️ Vista de Sesiones Mejorada
|
||||
|
||||
La vista de Sesiones (`/sessions`) ahora incluye información completa sobre el estado de actividad:
|
||||
|
||||
### Estadísticas Ampliadas
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ [Total: 10] [Válidas: 8] [🟢 Conectadas: 5] │
|
||||
│ [🟡 Inactivas: 3] [🔴 Expiradas: 2] [Usuarios: 3] │
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Tabla de Sesiones
|
||||
|
||||
Cada fila muestra:
|
||||
- **Usuario**: nombre del usuario
|
||||
- **Dispositivo**: navegador, OS, IP
|
||||
- **Última Actividad**: tiempo relativo (ej: "Hace 2 minutos") + timestamp exacto
|
||||
- **Token**: primeros 16 caracteres
|
||||
- **Creada**: timestamp de creación
|
||||
- **Expira**: timestamp de expiración
|
||||
- **Estado**: doble indicador
|
||||
- ✅ Válida / 🔴 Expirada
|
||||
- 🟢 Conectado / 🟡 Inactivo (solo si válida)
|
||||
- **Acciones**: botón para eliminar sesión
|
||||
|
||||
### Diferencias de Estado
|
||||
|
||||
**✅ Válida + 🟢 Conectado:**
|
||||
- Sesión no expirada
|
||||
- Usuario con actividad reciente (< 5 minutos)
|
||||
- Probablemente conectado vía WebSocket
|
||||
|
||||
**✅ Válida + 🟡 Inactivo:**
|
||||
- Sesión no expirada
|
||||
- Usuario sin actividad reciente (> 5 minutos)
|
||||
- WebSocket desconectado o usuario inactivo
|
||||
|
||||
**🔴 Expirada:**
|
||||
- Sesión pasada la fecha de expiración
|
||||
- Se eliminará automáticamente por TTL de MongoDB
|
||||
- No se puede eliminar manualmente (botón deshabilitado)
|
||||
|
||||
## 📝 Notas Adicionales
|
||||
|
||||
- El sistema es compatible con múltiples pestañas/dispositivos por usuario
|
||||
- Cada conexión WebSocket se rastrea independientemente
|
||||
- Los heartbeats se reinician automáticamente si se reconecta
|
||||
- La actividad del usuario se detecta de forma pasiva (no intrusiva)
|
||||
- La vista de Sesiones se actualiza manualmente (botón "🔄 Actualizar")
|
||||
- Los usuarios activos se actualizan automáticamente cada 30 segundos
|
||||
|
||||
257
PAYMENTS.md
Normal file
@@ -0,0 +1,257 @@
|
||||
# Sistema de Pagos con Stripe
|
||||
|
||||
Este documento explica cómo configurar y usar el sistema de pagos integrado con Stripe en Wallabicher.
|
||||
|
||||
## Configuración
|
||||
|
||||
### 1. Crear cuenta de Stripe
|
||||
|
||||
1. Visita [https://stripe.com](https://stripe.com) y crea una cuenta
|
||||
2. Accede al Dashboard de Stripe
|
||||
3. Obtén tus claves API desde: Developers → API keys
|
||||
|
||||
### 2. Configurar variables de entorno
|
||||
|
||||
Crea un archivo `.env` en `/web/backend/` con las siguientes variables:
|
||||
|
||||
```bash
|
||||
# Stripe Configuration
|
||||
STRIPE_SECRET_KEY=sk_test_xxxxx # Tu clave secreta de Stripe
|
||||
STRIPE_WEBHOOK_SECRET=whsec_xxxxx # Secret del webhook (ver paso 3)
|
||||
BASE_URL=http://localhost # URL base de tu aplicación
|
||||
```
|
||||
|
||||
### 3. Configurar Webhooks de Stripe
|
||||
|
||||
Los webhooks son necesarios para que Stripe notifique a tu aplicación sobre eventos de pago:
|
||||
|
||||
1. Ve a Developers → Webhooks en el Dashboard de Stripe
|
||||
2. Haz clic en "Add endpoint"
|
||||
3. URL del endpoint: `https://tu-dominio.com/api/payments/webhook`
|
||||
4. Selecciona los siguientes eventos:
|
||||
- `checkout.session.completed`
|
||||
- `customer.subscription.updated`
|
||||
- `customer.subscription.deleted`
|
||||
- `invoice.payment_succeeded`
|
||||
- `invoice.payment_failed`
|
||||
5. Copia el "Signing secret" (whsec_xxxxx) y agrégalo a `STRIPE_WEBHOOK_SECRET`
|
||||
|
||||
### 4. Configurar productos y precios en Stripe (Opcional)
|
||||
|
||||
El sistema crea automáticamente los productos y precios en Stripe si no existen, pero puedes crearlos manualmente:
|
||||
|
||||
**Productos:**
|
||||
- Wallabicher Básico
|
||||
- Wallabicher Pro
|
||||
- Wallabicher Enterprise
|
||||
|
||||
**Precios:**
|
||||
- Cada producto debe tener precios mensual y anual
|
||||
- Los `lookup_key` deben seguir el formato: `{planId}_{billingPeriod}` (ej: `basic_monthly`, `pro_yearly`)
|
||||
|
||||
### 5. Actualizar Docker Compose
|
||||
|
||||
Descomenta las variables de Stripe en `docker-compose.yml`:
|
||||
|
||||
```yaml
|
||||
environment:
|
||||
- STRIPE_SECRET_KEY=${STRIPE_SECRET_KEY}
|
||||
- STRIPE_WEBHOOK_SECRET=${STRIPE_WEBHOOK_SECRET}
|
||||
- BASE_URL=${BASE_URL:-http://localhost}
|
||||
```
|
||||
|
||||
Luego crea un archivo `.env` en la raíz del proyecto:
|
||||
|
||||
```bash
|
||||
STRIPE_SECRET_KEY=sk_test_xxxxx
|
||||
STRIPE_WEBHOOK_SECRET=whsec_xxxxx
|
||||
BASE_URL=https://tu-dominio.com
|
||||
```
|
||||
|
||||
## Flujo de registro y pago
|
||||
|
||||
### 1. Registro de usuario
|
||||
|
||||
Los usuarios pueden registrarse desde la landing page o directamente en `/dashboard/register`:
|
||||
|
||||
1. Usuario selecciona un plan
|
||||
2. Completa el formulario de registro (usuario, email, contraseña)
|
||||
3. Si es plan gratuito:
|
||||
- Se crea la cuenta inmediatamente
|
||||
- Redirige al dashboard
|
||||
4. Si es plan de pago:
|
||||
- Se crea la cuenta
|
||||
- Se inicia sesión automáticamente
|
||||
- Redirige a Stripe Checkout
|
||||
|
||||
### 2. Checkout de Stripe
|
||||
|
||||
En Stripe Checkout el usuario:
|
||||
- Ingresa información de tarjeta
|
||||
- Confirma el pago
|
||||
- Es redirigido de vuelta al dashboard
|
||||
|
||||
### 3. Gestión de suscripción
|
||||
|
||||
Desde `/dashboard/subscription` el usuario puede:
|
||||
- Ver su plan actual y límites
|
||||
- Ver uso de búsquedas
|
||||
- Cambiar de plan
|
||||
- Cancelar suscripción
|
||||
- Reactivar suscripción cancelada
|
||||
- Gestionar método de pago (portal de Stripe)
|
||||
|
||||
## Endpoints API
|
||||
|
||||
### Pagos
|
||||
|
||||
- `POST /api/payments/create-checkout-session` - Crear sesión de checkout
|
||||
- `POST /api/payments/create-portal-session` - Abrir portal de cliente
|
||||
- `POST /api/payments/cancel-subscription` - Cancelar suscripción
|
||||
- `POST /api/payments/reactivate-subscription` - Reactivar suscripción
|
||||
- `POST /api/payments/webhook` - Webhook de Stripe (sin autenticación)
|
||||
|
||||
### Usuarios
|
||||
|
||||
- `POST /api/users/register` - Registro público
|
||||
- `POST /api/users/login` - Iniciar sesión
|
||||
- `POST /api/users/logout` - Cerrar sesión
|
||||
- `GET /api/users/me` - Información del usuario actual
|
||||
|
||||
### Suscripciones
|
||||
|
||||
- `GET /api/subscription/plans` - Obtener planes disponibles
|
||||
- `GET /api/subscription/me` - Obtener suscripción actual
|
||||
- `PUT /api/subscription/me` - Actualizar suscripción
|
||||
|
||||
## Límites de planes
|
||||
|
||||
Los límites se aplican automáticamente mediante middleware:
|
||||
|
||||
### Plan Gratuito
|
||||
- 2 búsquedas simultáneas
|
||||
- Solo Wallapop
|
||||
- 50 notificaciones/día
|
||||
|
||||
### Plan Básico (€9.99/mes)
|
||||
- 5 búsquedas simultáneas
|
||||
- Wallapop + Vinted
|
||||
- 200 notificaciones/día
|
||||
|
||||
### Plan Pro (€19.99/mes)
|
||||
- 15 búsquedas simultáneas
|
||||
- Todas las plataformas
|
||||
- 1000 notificaciones/día
|
||||
|
||||
### Plan Enterprise (€49.99/mes)
|
||||
- Búsquedas ilimitadas
|
||||
- Todas las plataformas
|
||||
- Notificaciones ilimitadas
|
||||
|
||||
## Modo de prueba vs Producción
|
||||
|
||||
### Modo de prueba (Desarrollo)
|
||||
|
||||
Usa claves de prueba (`sk_test_xxxxx`):
|
||||
- No se procesan pagos reales
|
||||
- Usa tarjetas de prueba: `4242 4242 4242 4242`
|
||||
- Los webhooks requieren Stripe CLI o túnel (ngrok)
|
||||
|
||||
Para probar webhooks localmente:
|
||||
|
||||
```bash
|
||||
stripe listen --forward-to localhost:3001/api/payments/webhook
|
||||
```
|
||||
|
||||
### Modo producción
|
||||
|
||||
#### 1. Obtener claves LIVE de Stripe
|
||||
|
||||
1. Ve a https://dashboard.stripe.com/apikeys (modo **LIVE**)
|
||||
2. Copia la clave secreta `sk_live_...`
|
||||
3. **¡IMPORTANTE!** Nunca hagas commit de esta clave a git
|
||||
|
||||
#### 2. Configurar webhook en Stripe Dashboard
|
||||
|
||||
1. Ve a https://dashboard.stripe.com/webhooks (modo **LIVE**)
|
||||
2. Click en **"Add endpoint"**
|
||||
3. **Endpoint URL:** Tu dominio + la ruta del webhook
|
||||
```
|
||||
https://tudominio.com/api/payments/webhook
|
||||
```
|
||||
Ejemplos:
|
||||
- `https://wallabag.midominio.com/api/payments/webhook`
|
||||
- `https://api.miapp.com/api/payments/webhook`
|
||||
|
||||
4. **Selecciona estos eventos:**
|
||||
- `checkout.session.completed`
|
||||
- `customer.subscription.updated`
|
||||
- `customer.subscription.deleted`
|
||||
- `invoice.payment_succeeded`
|
||||
- `invoice.payment_failed`
|
||||
|
||||
5. **Guarda** y copia el **Webhook signing secret** (`whsec_...`)
|
||||
|
||||
#### 3. Variables de entorno en tu servidor
|
||||
|
||||
**Docker Compose (VPS/servidor propio):**
|
||||
```bash
|
||||
# En tu servidor, edita .env
|
||||
STRIPE_SECRET_KEY=sk_live_TU_CLAVE_REAL
|
||||
STRIPE_WEBHOOK_SECRET=whsec_SECRET_DEL_WEBHOOK
|
||||
BASE_URL=https://tudominio.com
|
||||
|
||||
# Reinicia los contenedores
|
||||
docker-compose restart backend
|
||||
```
|
||||
|
||||
**Heroku:**
|
||||
```bash
|
||||
heroku config:set STRIPE_SECRET_KEY=sk_live_...
|
||||
heroku config:set STRIPE_WEBHOOK_SECRET=whsec_...
|
||||
heroku config:set BASE_URL=https://tu-app.herokuapp.com
|
||||
```
|
||||
|
||||
**Railway/Render/Vercel:**
|
||||
Añade las variables en el panel de configuración del servicio.
|
||||
|
||||
#### 4. ⚠️ Requisitos obligatorios
|
||||
|
||||
- **HTTPS habilitado** (Stripe requiere HTTPS en producción)
|
||||
- Certificado SSL válido
|
||||
- Dominio accesible públicamente
|
||||
|
||||
## Seguridad
|
||||
|
||||
- Las claves de Stripe NUNCA deben exponerse en el frontend
|
||||
- Los webhooks verifican la firma automáticamente
|
||||
- Las rutas de pago requieren autenticación (excepto webhook)
|
||||
- Los administradores pueden gestionar suscripciones de todos los usuarios
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Los webhooks no funcionan
|
||||
|
||||
1. Verifica que `STRIPE_WEBHOOK_SECRET` esté configurado
|
||||
2. Verifica que la URL del webhook en Stripe sea correcta
|
||||
3. En desarrollo, usa `stripe listen` para reenviar webhooks
|
||||
4. Revisa los logs en Stripe Dashboard → Developers → Webhooks
|
||||
|
||||
### Los pagos no se reflejan
|
||||
|
||||
1. Verifica que el webhook esté recibiendo eventos
|
||||
2. Verifica logs del backend para errores
|
||||
3. Verifica que los metadatos (userId, planId) se estén enviando correctamente
|
||||
|
||||
### Error "Stripe no está configurado"
|
||||
|
||||
- Verifica que `STRIPE_SECRET_KEY` esté en variables de entorno
|
||||
- Reinicia el servidor backend después de configurar
|
||||
|
||||
## Recursos
|
||||
|
||||
- [Documentación de Stripe](https://stripe.com/docs)
|
||||
- [Stripe Checkout](https://stripe.com/docs/payments/checkout)
|
||||
- [Stripe Webhooks](https://stripe.com/docs/webhooks)
|
||||
- [Stripe CLI](https://stripe.com/docs/stripe-cli)
|
||||
|
||||
@@ -44,7 +44,7 @@ Abre: **http://localhost:3000**
|
||||
|
||||
| Servicio | Puerto | Descripción |
|
||||
|----------|--------|-------------|
|
||||
| **Frontend** | 3000 | Interfaz web Vue |
|
||||
| **Dashboard** | 3000 | Interfaz web Vue |
|
||||
| **Backend** | 3001 | API Node.js |
|
||||
| **Redis** | 6379 | Cache de artículos |
|
||||
| **Wallabicher** | - | Servicio Python (interno) |
|
||||
|
||||
@@ -37,6 +37,9 @@ services:
|
||||
- MONGODB_DATABASE=wallabicher
|
||||
- MONGODB_USERNAME=admin
|
||||
- MONGODB_PASSWORD=adminpassword
|
||||
- STRIPE_SECRET_KEY=sk_test_51SrpOfH73CrYqhOp2NfijzzU07ADADmwigscMVdLGzKu9zA83dsrODhfsaY1X4EFTSihhIB0lVtDQ2HpeOfMWTur00YLuuktSL
|
||||
- STRIPE_WEBHOOK_SECRET=whsec_8ebec8c2aa82a791aa9f2cd68211e297a5d172aea62ebd7b771d230e3a597aa8
|
||||
- BASE_URL=https://wb.pribyte.cloud/api
|
||||
volumes:
|
||||
# Montar archivos de configuración y datos en ubicación predecible
|
||||
- ./config.yaml:/data/config.yaml:ro
|
||||
@@ -55,18 +58,46 @@ services:
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
|
||||
# Frontend Vue
|
||||
frontend:
|
||||
# Dashboard Vue
|
||||
dashboard:
|
||||
build:
|
||||
context: ./web/frontend
|
||||
context: ./web/dashboard
|
||||
dockerfile: Dockerfile
|
||||
container_name: wallabicher-frontend
|
||||
container_name: wallabicher-dashboard
|
||||
environment:
|
||||
- NGINX_CONF=nginx-dashboard.conf
|
||||
volumes:
|
||||
- ./web/dashboard/nginx-dashboard.conf:/etc/nginx/conf.d/default.conf:ro
|
||||
depends_on:
|
||||
- backend
|
||||
networks:
|
||||
- wallabicher-network
|
||||
restart: unless-stopped
|
||||
|
||||
# Landing page (Astro)
|
||||
landing:
|
||||
build:
|
||||
context: ./web/landing
|
||||
dockerfile: Dockerfile
|
||||
container_name: wallabicher-landing
|
||||
networks:
|
||||
- wallabicher-network
|
||||
restart: unless-stopped
|
||||
|
||||
# Nginx reverse proxy principal
|
||||
nginx:
|
||||
image: nginx:alpine
|
||||
container_name: wallabicher-nginx
|
||||
volumes:
|
||||
- ./web/nginx.conf:/etc/nginx/conf.d/default.conf:ro
|
||||
depends_on:
|
||||
- backend
|
||||
- dashboard
|
||||
- landing
|
||||
networks:
|
||||
- wallabicher-network
|
||||
restart: unless-stopped
|
||||
|
||||
# Servicio Python principal (Wallabicher)
|
||||
# NOTA: Para usar MongoDB, asegúrate de que config.yaml tenga:
|
||||
# cache:
|
||||
|
||||
1
landing/.astro/types.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/// <reference types="astro/client" />
|
||||
1
landing/src/env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/// <reference path="../.astro/types.d.ts" />
|
||||
@@ -20,10 +20,10 @@
|
||||
"docker:clean": "docker compose down -v && docker system prune -f",
|
||||
"backend:dev": "cd web/backend && npm run dev",
|
||||
"backend:start": "cd web/backend && npm start",
|
||||
"frontend:dev": "cd web/frontend && npm run dev",
|
||||
"frontend:build": "cd web/frontend && npm run build",
|
||||
"frontend:preview": "cd web/frontend && npm run preview",
|
||||
"install:all": "cd web/backend && npm install && cd ../frontend && npm install",
|
||||
"dashboard:dev": "cd web/dashboard && npm run dev",
|
||||
"dashboard:build": "cd web/dashboard && npm run build",
|
||||
"dashboard:preview": "cd web/dashboard && npm run preview",
|
||||
"install:all": "cd web/backend && npm install && cd ../dashboard && npm install",
|
||||
"git:status": "git status",
|
||||
"git:pull": "git pull",
|
||||
"git:push": "git push",
|
||||
|
||||
205
web/DEVELOPMENT.md
Normal file
@@ -0,0 +1,205 @@
|
||||
# Guía de Desarrollo
|
||||
|
||||
## Configuración de Puertos
|
||||
|
||||
### Desarrollo Local
|
||||
| Servicio | Puerto | URL |
|
||||
|----------|--------|-----|
|
||||
| Dashboard (Vue) | 3000 | http://localhost:3000 |
|
||||
| Backend (API) | 3001 | http://localhost:3001 |
|
||||
| Landing (Astro) | 3002 | http://localhost:3002 |
|
||||
|
||||
### Producción (Docker)
|
||||
| Servicio | Puerto Externo | Puerto Interno |
|
||||
|----------|----------------|----------------|
|
||||
| Nginx Proxy | 80 | - |
|
||||
| Dashboard | - | 80 |
|
||||
| Backend | - | 3001 |
|
||||
| Landing | - | 80 |
|
||||
|
||||
## Ejecutar en Desarrollo Local
|
||||
|
||||
### 1. Backend (API)
|
||||
|
||||
```bash
|
||||
cd web/backend
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
El backend estará disponible en `http://localhost:3001`
|
||||
|
||||
**Endpoints**:
|
||||
- API: `http://localhost:3001/api/*`
|
||||
- WebSocket: `ws://localhost:3001/ws`
|
||||
|
||||
### 2. Dashboard (Vue)
|
||||
|
||||
```bash
|
||||
cd web/dashboard
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
El dashboard estará disponible en `http://localhost:3000`
|
||||
|
||||
**Nota**: Vite está configurado con proxy automático:
|
||||
- `/api/*` → `http://localhost:3001/api/*`
|
||||
- `/ws` → `ws://localhost:3001/ws`
|
||||
|
||||
Esto significa que puedes hacer peticiones a `/api/users` desde el dashboard y automáticamente se redirigirán al backend.
|
||||
|
||||
### 3. Landing (Astro)
|
||||
|
||||
```bash
|
||||
cd web/landing
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
La landing estará disponible en `http://localhost:3002`
|
||||
|
||||
## Ejecutar con Docker (Producción)
|
||||
|
||||
```bash
|
||||
# Construir imágenes
|
||||
docker-compose build
|
||||
|
||||
# Iniciar servicios
|
||||
docker-compose up -d
|
||||
|
||||
# Ver logs
|
||||
docker-compose logs -f
|
||||
|
||||
# Detener servicios
|
||||
docker-compose down
|
||||
```
|
||||
|
||||
Todo estará disponible en `http://localhost`:
|
||||
- Landing: `http://localhost/`
|
||||
- Dashboard: `http://localhost/dashboard/`
|
||||
- API: `http://localhost/api/`
|
||||
|
||||
## Reconstruir después de cambios
|
||||
|
||||
### Cambios en código (desarrollo)
|
||||
|
||||
No es necesario hacer nada, Vite y Node tienen hot-reload automático.
|
||||
|
||||
### Cambios en configuración o Docker
|
||||
|
||||
```bash
|
||||
# Reconstruir servicio específico
|
||||
docker-compose build dashboard
|
||||
docker-compose build backend
|
||||
docker-compose build landing
|
||||
|
||||
# Reconstruir todo
|
||||
docker-compose build
|
||||
|
||||
# Reiniciar servicios
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
## Diferencias entre Desarrollo y Producción
|
||||
|
||||
### Dashboard
|
||||
- **Desarrollo**:
|
||||
- Puerto 3000
|
||||
- Vite proxy activo para `/api` y `/ws`
|
||||
- Hot Module Replacement (HMR)
|
||||
- Source maps
|
||||
|
||||
- **Producción**:
|
||||
- Puerto 80 (interno)
|
||||
- Sin proxy (nginx principal maneja todo)
|
||||
- Código minificado y optimizado
|
||||
- Assets con hash para cache
|
||||
|
||||
### Backend
|
||||
- **Desarrollo**:
|
||||
- Puerto 3001
|
||||
- `node --watch` para auto-reload
|
||||
- Variables de entorno por defecto
|
||||
|
||||
- **Producción**:
|
||||
- Puerto 3001 (interno)
|
||||
- `node` sin watch
|
||||
- Variables de entorno de Docker
|
||||
|
||||
### Landing
|
||||
- **Desarrollo**:
|
||||
- Puerto 3002
|
||||
- Servidor de desarrollo de Astro
|
||||
- Hot reload
|
||||
|
||||
- **Producción**:
|
||||
- Puerto 80 (interno)
|
||||
- Archivos estáticos servidos por nginx
|
||||
- Pre-renderizado
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Puerto ya en uso
|
||||
|
||||
```bash
|
||||
# Ver qué está usando el puerto
|
||||
lsof -i :3000 # Dashboard
|
||||
lsof -i :3001 # Backend
|
||||
lsof -i :3002 # Landing
|
||||
|
||||
# Matar proceso
|
||||
kill -9 <PID>
|
||||
```
|
||||
|
||||
### Proxy no funciona en desarrollo
|
||||
|
||||
Asegúrate de que:
|
||||
1. El backend está corriendo en el puerto 3001
|
||||
2. Estás ejecutando `npm run dev` (modo desarrollo)
|
||||
3. La configuración de proxy en `vite.config.js` está correcta
|
||||
|
||||
### Assets 404 en producción
|
||||
|
||||
Ver `NGINX_CONFIG.md` para detalles de configuración de nginx y assets.
|
||||
|
||||
## Variables de Entorno
|
||||
|
||||
### Backend (desarrollo local)
|
||||
|
||||
Crear archivo `.env` en `web/backend/`:
|
||||
|
||||
```env
|
||||
PORT=3001
|
||||
PROJECT_ROOT=/ruta/al/proyecto
|
||||
MONGODB_HOST=localhost
|
||||
MONGODB_PORT=27017
|
||||
MONGODB_DATABASE=wallabicher
|
||||
MONGODB_USERNAME=admin
|
||||
MONGODB_PASSWORD=adminpassword
|
||||
```
|
||||
|
||||
### Backend (Docker)
|
||||
|
||||
Las variables se configuran en `docker-compose.yml`:
|
||||
- `PORT=3001`
|
||||
- `PROJECT_ROOT=/data`
|
||||
- Credenciales de MongoDB
|
||||
|
||||
## Base URLs
|
||||
|
||||
### Dashboard
|
||||
|
||||
La configuración de `base: '/dashboard/'` en `vite.config.js` hace que:
|
||||
- Todos los assets se construyan con prefijo `/dashboard/`
|
||||
- Vue Router use `/dashboard` como base
|
||||
- Service Worker se registre en `/dashboard/sw.js`
|
||||
|
||||
**No cambiar** a menos que quieras cambiar la ruta del dashboard.
|
||||
|
||||
### Landing
|
||||
|
||||
Sin base (raíz por defecto). Se sirve desde `/`.
|
||||
|
||||
**No cambiar** a menos que quieras mover la landing a otra ruta.
|
||||
|
||||
149
web/NGINX_CONFIG.md
Normal file
@@ -0,0 +1,149 @@
|
||||
# Configuración de Nginx y Assets
|
||||
|
||||
## Arquitectura
|
||||
|
||||
```
|
||||
Usuario
|
||||
↓
|
||||
nginx (proxy principal) :80
|
||||
├─→ /api → backend:3001
|
||||
├─→ /ws → backend:3001
|
||||
├─→ /dashboard → dashboard:80
|
||||
└─→ / → landing:80
|
||||
```
|
||||
|
||||
## Configuración de rutas
|
||||
|
||||
### 1. Dashboard (Vue + Vite)
|
||||
|
||||
**Base URL**: `/dashboard/`
|
||||
|
||||
- **Vite config**: `base: '/dashboard/'` (siempre)
|
||||
- **Vue Router**: `createWebHistory('/dashboard')`
|
||||
- **Nginx interno**: Sirve desde `/usr/share/nginx/html/dashboard/`
|
||||
- **Assets**: Se construyen con prefijo `/dashboard/` automáticamente por Vite
|
||||
|
||||
**Flujo de peticiones**:
|
||||
```
|
||||
Usuario → http://localhost/dashboard/
|
||||
↓
|
||||
nginx proxy → http://dashboard:80/dashboard/
|
||||
↓
|
||||
nginx dashboard → /usr/share/nginx/html/dashboard/index.html
|
||||
```
|
||||
|
||||
**Assets**:
|
||||
```
|
||||
Usuario → http://localhost/dashboard/assets/index-abc123.js
|
||||
↓
|
||||
nginx proxy → http://dashboard:80/dashboard/assets/index-abc123.js
|
||||
↓
|
||||
nginx dashboard → /usr/share/nginx/html/dashboard/assets/index-abc123.js
|
||||
```
|
||||
|
||||
### 2. Landing (Astro)
|
||||
|
||||
**Base URL**: `/`
|
||||
|
||||
- **Astro config**: Sin base (raíz por defecto)
|
||||
- **Nginx interno**: Sirve desde `/usr/share/nginx/html/`
|
||||
- **Nginx config**: `web/landing/nginx.conf`
|
||||
- **Assets**: Se construyen para la raíz
|
||||
|
||||
**Flujo de peticiones**:
|
||||
```
|
||||
Usuario → http://localhost/
|
||||
↓
|
||||
nginx proxy → http://landing:80/
|
||||
↓
|
||||
nginx landing → /usr/share/nginx/html/index.html
|
||||
```
|
||||
|
||||
**Assets (ej: logo.jpg)**:
|
||||
```
|
||||
Usuario → http://localhost/logo.jpg
|
||||
↓
|
||||
nginx proxy → http://landing:80/logo.jpg
|
||||
↓
|
||||
nginx landing → /usr/share/nginx/html/logo.jpg
|
||||
```
|
||||
|
||||
## Puertos
|
||||
|
||||
### Desarrollo local
|
||||
```
|
||||
Dashboard (Vue): http://localhost:3000
|
||||
Backend (API): http://localhost:3001
|
||||
Landing (Astro): http://localhost:3002
|
||||
```
|
||||
|
||||
### Producción (Docker)
|
||||
```
|
||||
nginx (proxy): :80 (externo)
|
||||
├─ dashboard: :80 (interno)
|
||||
├─ backend: :3001 (interno)
|
||||
└─ landing: :80 (interno)
|
||||
```
|
||||
|
||||
## Desarrollo local
|
||||
|
||||
Para desarrollo local, el proxy de Vite está configurado solo para `mode === 'development'`:
|
||||
|
||||
```bash
|
||||
# Terminal 1: Backend
|
||||
cd web/backend
|
||||
npm run dev
|
||||
# → API en http://localhost:3001
|
||||
|
||||
# Terminal 2: Dashboard
|
||||
cd web/dashboard
|
||||
npm run dev
|
||||
# → Dashboard en http://localhost:3000
|
||||
# → Vite proxy activo: /api → localhost:3001, /ws → localhost:3001
|
||||
|
||||
# Terminal 3: Landing
|
||||
cd web/landing
|
||||
npm run dev
|
||||
# → Landing en http://localhost:3002
|
||||
|
||||
# Producción (Docker)
|
||||
docker-compose up -d
|
||||
# → Todo en http://localhost:80
|
||||
```
|
||||
|
||||
## Reconstruir después de cambios
|
||||
|
||||
Si cambias la configuración de nginx o los archivos de configuración:
|
||||
|
||||
```bash
|
||||
# Reconstruir solo el dashboard
|
||||
docker-compose build dashboard
|
||||
|
||||
# Reconstruir solo la landing
|
||||
docker-compose build landing
|
||||
|
||||
# Reconstruir todo
|
||||
docker-compose build
|
||||
|
||||
# Reiniciar servicios
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Assets 404 en dashboard
|
||||
|
||||
1. Verificar que Vite construyó con `base: '/dashboard/'`
|
||||
2. Verificar que el Dockerfile copia a `/usr/share/nginx/html/dashboard`
|
||||
3. Verificar que nginx-dashboard.conf tiene la location `/dashboard/`
|
||||
|
||||
### Assets 404 en landing
|
||||
|
||||
1. Verificar que Astro construyó sin base (raíz)
|
||||
2. Verificar que el Dockerfile copia a `/usr/share/nginx/html/`
|
||||
3. Verificar que nginx landing usa root `/usr/share/nginx/html`
|
||||
|
||||
### Service Worker no se registra
|
||||
|
||||
El Service Worker debe estar en `/dashboard/sw.js` y registrarse con scope `/dashboard/`
|
||||
|
||||
@@ -10,9 +10,9 @@ cd web/backend
|
||||
npm install
|
||||
```
|
||||
|
||||
**Frontend:**
|
||||
**Dashboard:**
|
||||
```bash
|
||||
cd web/frontend
|
||||
cd web/dashboard
|
||||
npm install
|
||||
```
|
||||
|
||||
|
||||
@@ -18,7 +18,6 @@ COPY middlewares/ ./middlewares/
|
||||
COPY routes/ ./routes/
|
||||
COPY services/ ./services/
|
||||
COPY utils/ ./utils/
|
||||
COPY workers.json ./workers.json
|
||||
|
||||
# Exponer puerto
|
||||
EXPOSE 3001
|
||||
|
||||
120
web/backend/config/subscriptionPlans.js
Normal file
@@ -0,0 +1,120 @@
|
||||
// Planes de suscripción con sus límites y precios
|
||||
export const SUBSCRIPTION_PLANS = {
|
||||
free: {
|
||||
id: 'free',
|
||||
name: 'Gratis',
|
||||
description: 'Perfecto para empezar',
|
||||
price: {
|
||||
monthly: 0,
|
||||
yearly: 0,
|
||||
},
|
||||
limits: {
|
||||
maxWorkers: 2, // Número máximo de búsquedas/workers
|
||||
maxNotificationsPerDay: 50,
|
||||
platforms: ['wallapop'], // Solo Wallapop
|
||||
},
|
||||
features: [
|
||||
'Hasta 2 búsquedas simultáneas',
|
||||
'Solo Wallapop',
|
||||
'50 notificaciones por día',
|
||||
'Soporte por email',
|
||||
],
|
||||
},
|
||||
basic: {
|
||||
id: 'basic',
|
||||
name: 'Básico',
|
||||
description: 'Para usuarios ocasionales',
|
||||
price: {
|
||||
monthly: 9.99,
|
||||
yearly: 99.99, // ~17% descuento
|
||||
},
|
||||
limits: {
|
||||
maxWorkers: 5,
|
||||
maxNotificationsPerDay: 200,
|
||||
platforms: ['wallapop', 'vinted'],
|
||||
},
|
||||
features: [
|
||||
'Hasta 5 búsquedas simultáneas',
|
||||
'Wallapop y Vinted',
|
||||
'200 notificaciones por día',
|
||||
'Soporte prioritario',
|
||||
'Sin límite de favoritos',
|
||||
],
|
||||
},
|
||||
pro: {
|
||||
id: 'pro',
|
||||
name: 'Pro',
|
||||
description: 'Para usuarios avanzados',
|
||||
price: {
|
||||
monthly: 19.99,
|
||||
yearly: 199.99, // ~17% descuento
|
||||
},
|
||||
limits: {
|
||||
maxWorkers: 15,
|
||||
maxNotificationsPerDay: 1000,
|
||||
platforms: ['wallapop', 'vinted', 'buyee'],
|
||||
},
|
||||
features: [
|
||||
'Hasta 15 búsquedas simultáneas',
|
||||
'Todas las plataformas',
|
||||
'1000 notificaciones por día',
|
||||
'Soporte prioritario 24/7',
|
||||
'API access',
|
||||
'Webhooks personalizados',
|
||||
],
|
||||
},
|
||||
enterprise: {
|
||||
id: 'enterprise',
|
||||
name: 'Enterprise',
|
||||
description: 'Para equipos y uso intensivo',
|
||||
price: {
|
||||
monthly: 49.99,
|
||||
yearly: 499.99, // ~17% descuento
|
||||
},
|
||||
limits: {
|
||||
maxWorkers: -1, // Ilimitado
|
||||
maxNotificationsPerDay: -1, // Ilimitado
|
||||
platforms: ['wallapop', 'vinted', 'buyee'], // Todas
|
||||
},
|
||||
features: [
|
||||
'Búsquedas ilimitadas',
|
||||
'Notificaciones ilimitadas',
|
||||
'Todas las plataformas',
|
||||
'Soporte dedicado',
|
||||
'API completa',
|
||||
'Webhooks personalizados',
|
||||
'Gestión de múltiples usuarios',
|
||||
'Estadísticas avanzadas',
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
// Obtener plan por ID
|
||||
export function getPlan(planId) {
|
||||
return SUBSCRIPTION_PLANS[planId] || SUBSCRIPTION_PLANS.free;
|
||||
}
|
||||
|
||||
// Verificar si un plan tiene una característica
|
||||
export function hasFeature(planId, feature) {
|
||||
const plan = getPlan(planId);
|
||||
return plan.features.includes(feature);
|
||||
}
|
||||
|
||||
// Verificar si un plan tiene acceso a una plataforma
|
||||
export function hasPlatformAccess(planId, platform) {
|
||||
const plan = getPlan(planId);
|
||||
return plan.limits.platforms.includes(platform) || plan.limits.platforms.length === 0;
|
||||
}
|
||||
|
||||
// Obtener límite de workers para un plan
|
||||
export function getMaxWorkers(planId) {
|
||||
const plan = getPlan(planId);
|
||||
return plan.limits.maxWorkers;
|
||||
}
|
||||
|
||||
// Obtener límite de notificaciones diarias
|
||||
export function getMaxNotificationsPerDay(planId) {
|
||||
const plan = getPlan(planId);
|
||||
return plan.limits.maxNotificationsPerDay;
|
||||
}
|
||||
|
||||
14
web/backend/env.example.txt
Normal file
@@ -0,0 +1,14 @@
|
||||
# Stripe Configuration
|
||||
STRIPE_SECRET_KEY=sk_test_your_stripe_secret_key_here
|
||||
STRIPE_WEBHOOK_SECRET=whsec_your_webhook_secret_here
|
||||
|
||||
# Base URL for redirects (production/development)
|
||||
BASE_URL=http://localhost
|
||||
|
||||
# MongoDB Configuration (optional, if not in config.yaml)
|
||||
# MONGODB_HOST=localhost
|
||||
# MONGODB_PORT=27017
|
||||
# MONGODB_DATABASE=wallabicher
|
||||
# MONGODB_USERNAME=
|
||||
# MONGODB_PASSWORD=
|
||||
|
||||
133
web/backend/middlewares/subscriptionLimits.js
Normal file
@@ -0,0 +1,133 @@
|
||||
import { getUser, getUserSubscription, getWorkerCount } from '../services/mongodb.js';
|
||||
import { getPlan, getMaxWorkers, hasPlatformAccess } from '../config/subscriptionPlans.js';
|
||||
|
||||
// Middleware para verificar límites de suscripción
|
||||
export async function checkSubscriptionLimits(req, res, next) {
|
||||
try {
|
||||
const username = req.user?.username;
|
||||
|
||||
if (!username) {
|
||||
return res.status(401).json({ error: 'Authentication required' });
|
||||
}
|
||||
|
||||
// Obtener usuario y suscripción
|
||||
const user = await getUser(username);
|
||||
if (!user) {
|
||||
return res.status(404).json({ error: 'Usuario no encontrado' });
|
||||
}
|
||||
|
||||
// Si es admin, no aplicar límites
|
||||
if (user.role === 'admin') {
|
||||
return next();
|
||||
}
|
||||
|
||||
const subscription = await getUserSubscription(username);
|
||||
const planId = subscription?.planId || 'free';
|
||||
const plan = getPlan(planId);
|
||||
|
||||
// Añadir información del plan al request para uso posterior
|
||||
req.userPlan = {
|
||||
planId,
|
||||
plan,
|
||||
subscription,
|
||||
};
|
||||
|
||||
next();
|
||||
} catch (error) {
|
||||
console.error('Error verificando límites de suscripción:', error);
|
||||
res.status(500).json({ error: 'Error verificando límites de suscripción' });
|
||||
}
|
||||
}
|
||||
|
||||
// Middleware para verificar límite de workers
|
||||
export async function checkWorkerLimit(req, res, next) {
|
||||
try {
|
||||
const username = req.user?.username;
|
||||
const userPlan = req.userPlan;
|
||||
|
||||
if (!userPlan) {
|
||||
// Si no hay userPlan, ejecutar checkSubscriptionLimits primero
|
||||
await checkSubscriptionLimits(req, res, () => {});
|
||||
if (res.headersSent) return;
|
||||
}
|
||||
|
||||
const planId = req.userPlan.planId;
|
||||
const maxWorkers = getMaxWorkers(planId);
|
||||
|
||||
// Si es ilimitado (-1), permitir
|
||||
if (maxWorkers === -1) {
|
||||
return next();
|
||||
}
|
||||
|
||||
// Obtener número actual de workers
|
||||
const currentWorkerCount = await getWorkerCount(username);
|
||||
|
||||
// Si estamos actualizando workers, verificar el nuevo número
|
||||
if (req.method === 'PUT' && req.body?.items) {
|
||||
const newWorkerCount = (req.body.items || []).filter(
|
||||
(item, index) => !(req.body.disabled || []).includes(index)
|
||||
).length;
|
||||
|
||||
if (newWorkerCount > maxWorkers) {
|
||||
return res.status(403).json({
|
||||
error: 'Límite de búsquedas excedido',
|
||||
message: `Tu plan actual (${req.userPlan.plan.name}) permite hasta ${maxWorkers} búsquedas simultáneas. Estás intentando crear ${newWorkerCount}.`,
|
||||
currentPlan: planId,
|
||||
maxWorkers,
|
||||
currentCount: currentWorkerCount,
|
||||
requestedCount: newWorkerCount,
|
||||
});
|
||||
}
|
||||
} else if (currentWorkerCount >= maxWorkers) {
|
||||
// Si ya alcanzó el límite y está intentando crear más
|
||||
return res.status(403).json({
|
||||
error: 'Límite de búsquedas alcanzado',
|
||||
message: `Tu plan actual (${req.userPlan.plan.name}) permite hasta ${maxWorkers} búsquedas simultáneas.`,
|
||||
currentPlan: planId,
|
||||
maxWorkers,
|
||||
currentCount: currentWorkerCount,
|
||||
});
|
||||
}
|
||||
|
||||
next();
|
||||
} catch (error) {
|
||||
console.error('Error verificando límite de workers:', error);
|
||||
res.status(500).json({ error: 'Error verificando límite de workers' });
|
||||
}
|
||||
}
|
||||
|
||||
// Middleware para verificar acceso a plataforma
|
||||
export async function checkPlatformAccess(req, res, next) {
|
||||
try {
|
||||
const platform = req.body?.platform || req.query?.platform;
|
||||
|
||||
if (!platform) {
|
||||
return next(); // Si no hay plataforma especificada, continuar
|
||||
}
|
||||
|
||||
const userPlan = req.userPlan;
|
||||
|
||||
if (!userPlan) {
|
||||
await checkSubscriptionLimits(req, res, () => {});
|
||||
if (res.headersSent) return;
|
||||
}
|
||||
|
||||
const planId = req.userPlan.planId;
|
||||
|
||||
if (!hasPlatformAccess(planId, platform)) {
|
||||
return res.status(403).json({
|
||||
error: 'Plataforma no disponible en tu plan',
|
||||
message: `La plataforma "${platform}" no está disponible en tu plan actual (${req.userPlan.plan.name}).`,
|
||||
currentPlan: planId,
|
||||
requestedPlatform: platform,
|
||||
availablePlatforms: req.userPlan.plan.limits.platforms,
|
||||
});
|
||||
}
|
||||
|
||||
next();
|
||||
} catch (error) {
|
||||
console.error('Error verificando acceso a plataforma:', error);
|
||||
res.status(500).json({ error: 'Error verificando acceso a plataforma' });
|
||||
}
|
||||
}
|
||||
|
||||
21
web/backend/package-lock.json
generated
@@ -15,6 +15,7 @@
|
||||
"express": "^4.18.2",
|
||||
"mongodb": "^6.3.0",
|
||||
"rate-limiter-flexible": "^5.0.3",
|
||||
"stripe": "^20.2.0",
|
||||
"web-push": "^3.6.7",
|
||||
"ws": "^8.14.2",
|
||||
"yaml": "^2.3.4"
|
||||
@@ -1780,6 +1781,26 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/stripe": {
|
||||
"version": "20.2.0",
|
||||
"resolved": "https://registry.npmjs.org/stripe/-/stripe-20.2.0.tgz",
|
||||
"integrity": "sha512-m8niTfdm3nPP/yQswRWMwQxqEUcTtB3RTJQ9oo6NINDzgi7aPOadsH/fPXIIfL1Sc5+lqQFKSk7WiO6CXmvaeA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"qs": "^6.14.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/node": ">=16"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/node": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/tar": {
|
||||
"version": "6.2.1",
|
||||
"resolved": "https://registry.npmjs.org/tar/-/tar-6.2.1.tgz",
|
||||
|
||||
@@ -20,8 +20,9 @@
|
||||
"chokidar": "^3.5.3",
|
||||
"cors": "^2.8.5",
|
||||
"express": "^4.18.2",
|
||||
"rate-limiter-flexible": "^5.0.3",
|
||||
"mongodb": "^6.3.0",
|
||||
"rate-limiter-flexible": "^5.0.3",
|
||||
"stripe": "^20.2.0",
|
||||
"web-push": "^3.6.7",
|
||||
"ws": "^8.14.2",
|
||||
"yaml": "^2.3.4"
|
||||
|
||||
@@ -30,6 +30,8 @@ router.get('/sessions', basicAuthMiddleware, adminAuthMiddleware, async (req, re
|
||||
const sessionsByUser = {};
|
||||
let activeSessions = 0;
|
||||
let expiredSessions = 0;
|
||||
let connectedSessions = 0; // Sesiones con actividad reciente
|
||||
let inactiveSessions = 0; // Sesiones sin actividad pero no expiradas
|
||||
|
||||
sessions.forEach(session => {
|
||||
if (!sessionsByUser[session.username]) {
|
||||
@@ -41,6 +43,13 @@ router.get('/sessions', basicAuthMiddleware, adminAuthMiddleware, async (req, re
|
||||
expiredSessions++;
|
||||
} else {
|
||||
activeSessions++;
|
||||
|
||||
// Contar sesiones conectadas (con actividad reciente)
|
||||
if (session.isActive) {
|
||||
connectedSessions++;
|
||||
} else {
|
||||
inactiveSessions++;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -50,6 +59,8 @@ router.get('/sessions', basicAuthMiddleware, adminAuthMiddleware, async (req, re
|
||||
total: sessions.length,
|
||||
active: activeSessions,
|
||||
expired: expiredSessions,
|
||||
connected: connectedSessions,
|
||||
inactive: inactiveSessions,
|
||||
byUser: sessionsByUser,
|
||||
},
|
||||
});
|
||||
|
||||
418
web/backend/routes/payments.js
Normal file
@@ -0,0 +1,418 @@
|
||||
import express from 'express';
|
||||
import { basicAuthMiddleware } from '../middlewares/auth.js';
|
||||
import {
|
||||
getStripeClient,
|
||||
createCheckoutSession,
|
||||
createCustomerPortalSession,
|
||||
cancelSubscription,
|
||||
reactivateSubscription,
|
||||
verifyWebhookSignature,
|
||||
} from '../services/stripe.js';
|
||||
import {
|
||||
getUser,
|
||||
updateUserSubscription,
|
||||
getUserSubscription,
|
||||
} from '../services/mongodb.js';
|
||||
import { getPlan } from '../config/subscriptionPlans.js';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// Middleware de autenticación opcional (intenta autenticar pero no falla si no hay token)
|
||||
async function optionalAuthMiddleware(req, res, next) {
|
||||
const authHeader = req.headers.authorization;
|
||||
|
||||
if (authHeader) {
|
||||
// Si hay header de autorización, intentar autenticar
|
||||
return basicAuthMiddleware(req, res, next);
|
||||
} else {
|
||||
// Si no hay header, continuar sin autenticación
|
||||
req.user = null;
|
||||
next();
|
||||
}
|
||||
}
|
||||
|
||||
// Crear sesión de checkout (permite autenticación opcional)
|
||||
// Puede ser llamado por usuarios autenticados o durante el registro
|
||||
router.post('/create-checkout-session', optionalAuthMiddleware, async (req, res) => {
|
||||
try {
|
||||
const stripeClient = getStripeClient();
|
||||
if (!stripeClient) {
|
||||
return res.status(503).json({
|
||||
error: 'Pagos no disponibles',
|
||||
message: 'El sistema de pagos no está configurado actualmente'
|
||||
});
|
||||
}
|
||||
|
||||
const { planId, billingPeriod, username: providedUsername, email: providedEmail } = req.body;
|
||||
|
||||
// Username puede venir del token (usuario autenticado) o del body (registro nuevo)
|
||||
const username = req.user?.username || providedUsername;
|
||||
|
||||
if (!username) {
|
||||
return res.status(400).json({
|
||||
error: 'Se requiere username (autenticación o en el body)'
|
||||
});
|
||||
}
|
||||
|
||||
if (!planId || !billingPeriod) {
|
||||
return res.status(400).json({
|
||||
error: 'planId y billingPeriod son requeridos'
|
||||
});
|
||||
}
|
||||
|
||||
if (planId === 'free') {
|
||||
return res.status(400).json({
|
||||
error: 'El plan gratuito no requiere pago'
|
||||
});
|
||||
}
|
||||
|
||||
if (!['monthly', 'yearly'].includes(billingPeriod)) {
|
||||
return res.status(400).json({
|
||||
error: 'billingPeriod debe ser monthly o yearly'
|
||||
});
|
||||
}
|
||||
|
||||
// Obtener usuario
|
||||
const user = await getUser(username);
|
||||
if (!user) {
|
||||
return res.status(404).json({ error: 'Usuario no encontrado' });
|
||||
}
|
||||
|
||||
// Email puede venir del usuario en BD, del body, o generar uno
|
||||
const email = user.email || providedEmail || `${username}@wallabicher.local`;
|
||||
|
||||
// Crear sesión de checkout
|
||||
const session = await createCheckoutSession({
|
||||
planId,
|
||||
billingPeriod,
|
||||
email,
|
||||
userId: username,
|
||||
});
|
||||
|
||||
console.log(`✅ Sesión de checkout creada para ${username}: ${planId} (${billingPeriod})`);
|
||||
res.json({
|
||||
success: true,
|
||||
sessionId: session.id,
|
||||
url: session.url,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error creando sesión de checkout:', error);
|
||||
res.status(500).json({
|
||||
error: error.message,
|
||||
message: 'Error al crear la sesión de pago'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Crear portal del cliente
|
||||
router.post('/create-portal-session', basicAuthMiddleware, async (req, res) => {
|
||||
try {
|
||||
const stripeClient = getStripeClient();
|
||||
if (!stripeClient) {
|
||||
return res.status(503).json({
|
||||
error: 'Pagos no disponibles',
|
||||
message: 'El sistema de pagos no está configurado actualmente'
|
||||
});
|
||||
}
|
||||
|
||||
const username = req.user.username;
|
||||
const subscription = await getUserSubscription(username);
|
||||
|
||||
if (!subscription || !subscription.stripeCustomerId) {
|
||||
return res.status(404).json({
|
||||
error: 'No se encontró información de cliente de Stripe'
|
||||
});
|
||||
}
|
||||
|
||||
const session = await createCustomerPortalSession(subscription.stripeCustomerId);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
url: session.url,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error creando portal de cliente:', error);
|
||||
res.status(500).json({
|
||||
error: error.message,
|
||||
message: 'Error al crear el portal de gestión'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Cancelar suscripción
|
||||
router.post('/cancel-subscription', basicAuthMiddleware, async (req, res) => {
|
||||
try {
|
||||
const stripeClient = getStripeClient();
|
||||
if (!stripeClient) {
|
||||
return res.status(503).json({
|
||||
error: 'Pagos no disponibles',
|
||||
message: 'El sistema de pagos no está configurado actualmente'
|
||||
});
|
||||
}
|
||||
|
||||
const username = req.user.username;
|
||||
const subscription = await getUserSubscription(username);
|
||||
|
||||
if (!subscription || !subscription.stripeSubscriptionId) {
|
||||
return res.status(404).json({
|
||||
error: 'No se encontró suscripción activa'
|
||||
});
|
||||
}
|
||||
|
||||
await cancelSubscription(subscription.stripeSubscriptionId);
|
||||
|
||||
// Actualizar en BD
|
||||
await updateUserSubscription(username, {
|
||||
...subscription,
|
||||
cancelAtPeriodEnd: true,
|
||||
});
|
||||
|
||||
console.log(`✅ Suscripción cancelada para ${username}`);
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Suscripción cancelada. Se mantendrá activa hasta el final del período'
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error cancelando suscripción:', error);
|
||||
res.status(500).json({
|
||||
error: error.message,
|
||||
message: 'Error al cancelar la suscripción'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Reactivar suscripción
|
||||
router.post('/reactivate-subscription', basicAuthMiddleware, async (req, res) => {
|
||||
try {
|
||||
const stripeClient = getStripeClient();
|
||||
if (!stripeClient) {
|
||||
return res.status(503).json({
|
||||
error: 'Pagos no disponibles',
|
||||
message: 'El sistema de pagos no está configurado actualmente'
|
||||
});
|
||||
}
|
||||
|
||||
const username = req.user.username;
|
||||
const subscription = await getUserSubscription(username);
|
||||
|
||||
if (!subscription || !subscription.stripeSubscriptionId) {
|
||||
return res.status(404).json({
|
||||
error: 'No se encontró suscripción'
|
||||
});
|
||||
}
|
||||
|
||||
await reactivateSubscription(subscription.stripeSubscriptionId);
|
||||
|
||||
// Actualizar en BD
|
||||
await updateUserSubscription(username, {
|
||||
...subscription,
|
||||
cancelAtPeriodEnd: false,
|
||||
});
|
||||
|
||||
console.log(`✅ Suscripción reactivada para ${username}`);
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Suscripción reactivada correctamente'
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error reactivando suscripción:', error);
|
||||
res.status(500).json({
|
||||
error: error.message,
|
||||
message: 'Error al reactivar la suscripción'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Webhook de Stripe (sin autenticación)
|
||||
// ⚠️ NOTA: express.raw() se aplica en server.js para esta ruta
|
||||
// De lo contrario el body ya viene parseado y Stripe no puede verificar la firma
|
||||
router.post('/webhook', async (req, res) => {
|
||||
const signature = req.headers['stripe-signature'];
|
||||
|
||||
if (!signature) {
|
||||
return res.status(400).json({ error: 'No stripe-signature header' });
|
||||
}
|
||||
|
||||
let event;
|
||||
|
||||
try {
|
||||
// Verificar firma del webhook
|
||||
event = verifyWebhookSignature(req.body, signature);
|
||||
} catch (error) {
|
||||
console.error('Error verificando webhook:', error.message);
|
||||
return res.status(400).json({ error: `Webhook verification failed: ${error.message}` });
|
||||
}
|
||||
|
||||
console.log(`📩 Webhook recibido: ${event.type}`);
|
||||
|
||||
// Manejar eventos de Stripe
|
||||
try {
|
||||
switch (event.type) {
|
||||
case 'checkout.session.completed':
|
||||
await handleCheckoutComplete(event.data.object);
|
||||
break;
|
||||
|
||||
case 'customer.subscription.updated':
|
||||
await handleSubscriptionUpdated(event.data.object);
|
||||
break;
|
||||
|
||||
case 'customer.subscription.deleted':
|
||||
await handleSubscriptionDeleted(event.data.object);
|
||||
break;
|
||||
|
||||
case 'invoice.payment_succeeded':
|
||||
await handlePaymentSucceeded(event.data.object);
|
||||
break;
|
||||
|
||||
case 'invoice.payment_failed':
|
||||
await handlePaymentFailed(event.data.object);
|
||||
break;
|
||||
|
||||
default:
|
||||
console.log(`ℹ️ Evento no manejado: ${event.type}`);
|
||||
}
|
||||
|
||||
res.json({ received: true });
|
||||
} catch (error) {
|
||||
console.error('Error procesando webhook:', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Handlers de eventos de Stripe
|
||||
|
||||
async function handleCheckoutComplete(session) {
|
||||
const userId = session.metadata.userId || session.client_reference_id;
|
||||
const planId = session.metadata.planId;
|
||||
const billingPeriod = session.metadata.billingPeriod;
|
||||
|
||||
if (!userId) {
|
||||
console.error('❌ Checkout sin userId en metadata');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`✅ Pago completado: ${userId} → ${planId} (${billingPeriod})`);
|
||||
|
||||
// Obtener detalles de la suscripción
|
||||
const subscriptionId = session.subscription;
|
||||
const customerId = session.customer;
|
||||
|
||||
const plan = getPlan(planId);
|
||||
const now = new Date();
|
||||
let periodEnd = new Date(now);
|
||||
|
||||
if (billingPeriod === 'yearly') {
|
||||
periodEnd.setFullYear(periodEnd.getFullYear() + 1);
|
||||
} else {
|
||||
periodEnd.setMonth(periodEnd.getMonth() + 1);
|
||||
}
|
||||
|
||||
// Actualizar suscripción en BD
|
||||
await updateUserSubscription(userId, {
|
||||
planId,
|
||||
status: 'active',
|
||||
billingPeriod,
|
||||
currentPeriodStart: now,
|
||||
currentPeriodEnd: periodEnd,
|
||||
cancelAtPeriodEnd: false,
|
||||
stripeCustomerId: customerId,
|
||||
stripeSubscriptionId: subscriptionId,
|
||||
});
|
||||
|
||||
// Activar usuario si estaba pendiente de pago
|
||||
const { getDB } = await import('../services/mongodb.js');
|
||||
const db = getDB();
|
||||
if (db) {
|
||||
const usersCollection = db.collection('users');
|
||||
const user = await usersCollection.findOne({ username: userId });
|
||||
|
||||
if (user && user.status === 'pending_payment') {
|
||||
await usersCollection.updateOne(
|
||||
{ username: userId },
|
||||
{
|
||||
$set: {
|
||||
status: 'active',
|
||||
activatedAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
}
|
||||
}
|
||||
);
|
||||
console.log(`✅ Usuario activado: ${userId}`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`✅ Suscripción actualizada en BD: ${userId}`);
|
||||
}
|
||||
|
||||
async function handleSubscriptionUpdated(subscription) {
|
||||
const userId = subscription.metadata.userId;
|
||||
|
||||
if (!userId) {
|
||||
console.error('❌ Subscription sin userId en metadata');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`📝 Suscripción actualizada: ${userId}`);
|
||||
|
||||
const status = subscription.status; // active, past_due, canceled, etc.
|
||||
const cancelAtPeriodEnd = subscription.cancel_at_period_end;
|
||||
const currentPeriodEnd = new Date(subscription.current_period_end * 1000);
|
||||
const currentPeriodStart = new Date(subscription.current_period_start * 1000);
|
||||
|
||||
// Actualizar en BD
|
||||
const currentSubscription = await getUserSubscription(userId);
|
||||
await updateUserSubscription(userId, {
|
||||
...currentSubscription,
|
||||
status,
|
||||
cancelAtPeriodEnd,
|
||||
currentPeriodStart,
|
||||
currentPeriodEnd,
|
||||
});
|
||||
|
||||
console.log(`✅ Suscripción actualizada en BD: ${userId} (${status})`);
|
||||
}
|
||||
|
||||
async function handleSubscriptionDeleted(subscription) {
|
||||
const userId = subscription.metadata.userId;
|
||||
|
||||
if (!userId) {
|
||||
console.error('❌ Subscription sin userId en metadata');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`❌ Suscripción eliminada: ${userId}`);
|
||||
|
||||
// Revertir a plan gratuito
|
||||
await updateUserSubscription(userId, {
|
||||
planId: 'free',
|
||||
status: 'canceled',
|
||||
cancelAtPeriodEnd: false,
|
||||
currentPeriodStart: new Date(),
|
||||
currentPeriodEnd: null,
|
||||
stripeCustomerId: subscription.customer,
|
||||
stripeSubscriptionId: null,
|
||||
});
|
||||
|
||||
console.log(`✅ Usuario revertido a plan gratuito: ${userId}`);
|
||||
}
|
||||
|
||||
async function handlePaymentSucceeded(invoice) {
|
||||
const subscriptionId = invoice.subscription;
|
||||
const customerId = invoice.customer;
|
||||
|
||||
console.log(`✅ Pago exitoso: ${customerId} - ${subscriptionId}`);
|
||||
|
||||
// Aquí podrías enviar un email de confirmación o actualizar estadísticas
|
||||
}
|
||||
|
||||
async function handlePaymentFailed(invoice) {
|
||||
const subscriptionId = invoice.subscription;
|
||||
const customerId = invoice.customer;
|
||||
|
||||
console.log(`❌ Pago fallido: ${customerId} - ${subscriptionId}`);
|
||||
|
||||
// Aquí podrías enviar un email de aviso o marcar la cuenta con problemas
|
||||
}
|
||||
|
||||
export default router;
|
||||
|
||||
208
web/backend/routes/subscription.js
Normal file
@@ -0,0 +1,208 @@
|
||||
import express from 'express';
|
||||
import { basicAuthMiddleware } from '../middlewares/auth.js';
|
||||
import { adminAuthMiddleware } from '../middlewares/adminAuth.js';
|
||||
import { getUser, getUserSubscription, updateUserSubscription, getWorkerCount } from '../services/mongodb.js';
|
||||
import { SUBSCRIPTION_PLANS, getPlan } from '../config/subscriptionPlans.js';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// Obtener planes disponibles (público)
|
||||
router.get('/plans', (req, res) => {
|
||||
try {
|
||||
const plans = Object.values(SUBSCRIPTION_PLANS).map(plan => ({
|
||||
id: plan.id,
|
||||
name: plan.name,
|
||||
description: plan.description,
|
||||
price: plan.price,
|
||||
limits: {
|
||||
maxWorkers: plan.limits.maxWorkers,
|
||||
maxNotificationsPerDay: plan.limits.maxNotificationsPerDay,
|
||||
platforms: plan.limits.platforms,
|
||||
},
|
||||
features: plan.features,
|
||||
}));
|
||||
|
||||
res.json({ plans });
|
||||
} catch (error) {
|
||||
console.error('Error obteniendo planes:', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Obtener suscripción del usuario actual
|
||||
router.get('/me', basicAuthMiddleware, async (req, res) => {
|
||||
try {
|
||||
const username = req.user.username;
|
||||
const subscription = await getUserSubscription(username);
|
||||
const user = await getUser(username);
|
||||
const workerCount = await getWorkerCount(username);
|
||||
|
||||
const planId = subscription?.planId || 'free';
|
||||
const plan = getPlan(planId);
|
||||
|
||||
res.json({
|
||||
subscription: {
|
||||
planId,
|
||||
plan: {
|
||||
id: plan.id,
|
||||
name: plan.name,
|
||||
description: plan.description,
|
||||
price: plan.price,
|
||||
limits: plan.limits,
|
||||
features: plan.features,
|
||||
},
|
||||
status: subscription?.status || 'active',
|
||||
currentPeriodStart: subscription?.currentPeriodStart || user?.createdAt,
|
||||
currentPeriodEnd: subscription?.currentPeriodEnd || null,
|
||||
cancelAtPeriodEnd: subscription?.cancelAtPeriodEnd || false,
|
||||
},
|
||||
usage: {
|
||||
workers: workerCount,
|
||||
maxWorkers: plan.limits.maxWorkers === -1 ? 'Ilimitado' : plan.limits.maxWorkers,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error obteniendo suscripción:', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Actualizar suscripción (requiere admin o para el propio usuario en caso de cancelación)
|
||||
router.put('/me', basicAuthMiddleware, async (req, res) => {
|
||||
try {
|
||||
const username = req.user.username;
|
||||
const { planId, status, cancelAtPeriodEnd } = req.body;
|
||||
|
||||
if (!planId) {
|
||||
return res.status(400).json({ error: 'planId es requerido' });
|
||||
}
|
||||
|
||||
// Verificar que el plan existe
|
||||
const plan = getPlan(planId);
|
||||
if (!plan) {
|
||||
return res.status(400).json({ error: 'Plan no válido' });
|
||||
}
|
||||
|
||||
// Solo permitir actualizar a plan gratuito o cancelar suscripción
|
||||
// Para actualizar a planes de pago, se requiere integración con pasarela de pago
|
||||
if (planId !== 'free' && !req.user.role === 'admin') {
|
||||
return res.status(403).json({
|
||||
error: 'Para actualizar a un plan de pago, contacta con soporte o usa la pasarela de pago'
|
||||
});
|
||||
}
|
||||
|
||||
const subscription = await getUserSubscription(username);
|
||||
|
||||
// Calcular fechas del período
|
||||
const now = new Date();
|
||||
let currentPeriodStart = subscription?.currentPeriodStart || now;
|
||||
let currentPeriodEnd = null;
|
||||
|
||||
if (planId !== 'free') {
|
||||
// Para planes de pago, establecer período mensual o anual según corresponda
|
||||
// Por ahora, asumimos mensual (30 días)
|
||||
currentPeriodEnd = new Date(now);
|
||||
currentPeriodEnd.setMonth(currentPeriodEnd.getMonth() + 1);
|
||||
}
|
||||
|
||||
await updateUserSubscription(username, {
|
||||
planId,
|
||||
status: status || 'active',
|
||||
currentPeriodStart,
|
||||
currentPeriodEnd,
|
||||
cancelAtPeriodEnd: cancelAtPeriodEnd || false,
|
||||
});
|
||||
|
||||
console.log(`✅ Suscripción actualizada para ${username}: ${planId}`);
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Suscripción actualizada correctamente',
|
||||
subscription: {
|
||||
planId,
|
||||
status: status || 'active',
|
||||
currentPeriodStart,
|
||||
currentPeriodEnd,
|
||||
cancelAtPeriodEnd: cancelAtPeriodEnd || false,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error actualizando suscripción:', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Obtener suscripción de cualquier usuario (solo admin)
|
||||
router.get('/:username', basicAuthMiddleware, adminAuthMiddleware, async (req, res) => {
|
||||
try {
|
||||
const { username } = req.params;
|
||||
const subscription = await getUserSubscription(username);
|
||||
const user = await getUser(username);
|
||||
const workerCount = await getWorkerCount(username);
|
||||
|
||||
const planId = subscription?.planId || 'free';
|
||||
const plan = getPlan(planId);
|
||||
|
||||
res.json({
|
||||
subscription: {
|
||||
planId,
|
||||
plan: {
|
||||
id: plan.id,
|
||||
name: plan.name,
|
||||
description: plan.description,
|
||||
price: plan.price,
|
||||
limits: plan.limits,
|
||||
features: plan.features,
|
||||
},
|
||||
status: subscription?.status || 'active',
|
||||
currentPeriodStart: subscription?.currentPeriodStart || user?.createdAt,
|
||||
currentPeriodEnd: subscription?.currentPeriodEnd || null,
|
||||
cancelAtPeriodEnd: subscription?.cancelAtPeriodEnd || false,
|
||||
},
|
||||
usage: {
|
||||
workers: workerCount,
|
||||
maxWorkers: plan.limits.maxWorkers === -1 ? 'Ilimitado' : plan.limits.maxWorkers,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error obteniendo suscripción:', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Actualizar suscripción de cualquier usuario (solo admin)
|
||||
router.put('/:username', basicAuthMiddleware, adminAuthMiddleware, async (req, res) => {
|
||||
try {
|
||||
const { username } = req.params;
|
||||
const { planId, status, currentPeriodStart, currentPeriodEnd, cancelAtPeriodEnd } = req.body;
|
||||
|
||||
if (!planId) {
|
||||
return res.status(400).json({ error: 'planId es requerido' });
|
||||
}
|
||||
|
||||
// Verificar que el plan existe
|
||||
const plan = getPlan(planId);
|
||||
if (!plan) {
|
||||
return res.status(400).json({ error: 'Plan no válido' });
|
||||
}
|
||||
|
||||
await updateUserSubscription(username, {
|
||||
planId,
|
||||
status: status || 'active',
|
||||
currentPeriodStart: currentPeriodStart ? new Date(currentPeriodStart) : new Date(),
|
||||
currentPeriodEnd: currentPeriodEnd ? new Date(currentPeriodEnd) : null,
|
||||
cancelAtPeriodEnd: cancelAtPeriodEnd || false,
|
||||
});
|
||||
|
||||
console.log(`✅ Suscripción actualizada para ${username}: ${planId} por admin ${req.user.username}`);
|
||||
res.json({
|
||||
success: true,
|
||||
message: `Suscripción de ${username} actualizada correctamente`,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error actualizando suscripción:', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -1,12 +1,105 @@
|
||||
import express from 'express';
|
||||
import bcrypt from 'bcrypt';
|
||||
import { getDB, getUser, createUser, deleteUser as deleteUserFromDB, getAllUsers, updateUserPassword } from '../services/mongodb.js';
|
||||
import { getDB, getUser, createUser, deleteUser as deleteUserFromDB, getAllUsers, updateUserPassword, getActiveSessions } from '../services/mongodb.js';
|
||||
import { basicAuthMiddleware, createSession, invalidateSession, invalidateUserSessions } from '../middlewares/auth.js';
|
||||
import { adminAuthMiddleware } from '../middlewares/adminAuth.js';
|
||||
import { combineFingerprint } from '../utils/fingerprint.js';
|
||||
import { getActiveUsers } from '../services/websocket.js';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// Endpoint de registro (público)
|
||||
router.post('/register', async (req, res) => {
|
||||
try {
|
||||
const db = getDB();
|
||||
if (!db) {
|
||||
return res.status(500).json({ error: 'MongoDB no está disponible' });
|
||||
}
|
||||
|
||||
const { username, password, email, planId } = req.body;
|
||||
|
||||
if (!username || !password) {
|
||||
return res.status(400).json({ error: 'username y password son requeridos' });
|
||||
}
|
||||
|
||||
if (username.length < 3) {
|
||||
return res.status(400).json({ error: 'El nombre de usuario debe tener al menos 3 caracteres' });
|
||||
}
|
||||
|
||||
if (password.length < 6) {
|
||||
return res.status(400).json({ error: 'La contraseña debe tener al menos 6 caracteres' });
|
||||
}
|
||||
|
||||
// Validar email si se proporciona
|
||||
if (email) {
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
if (!emailRegex.test(email)) {
|
||||
return res.status(400).json({ error: 'Email no válido' });
|
||||
}
|
||||
}
|
||||
|
||||
// Verificar si el usuario ya existe
|
||||
const existingUser = await getUser(username);
|
||||
if (existingUser) {
|
||||
return res.status(409).json({ error: 'El usuario ya existe' });
|
||||
}
|
||||
|
||||
// Verificar si es plan de pago y si Stripe está disponible
|
||||
const selectedPlanId = planId || 'free';
|
||||
const isPaidPlan = selectedPlanId !== 'free';
|
||||
|
||||
if (isPaidPlan) {
|
||||
// Para planes de pago, verificar que Stripe esté configurado
|
||||
const { getStripeClient } = await import('../services/stripe.js');
|
||||
const stripeClient = getStripeClient();
|
||||
|
||||
if (!stripeClient) {
|
||||
return res.status(503).json({
|
||||
error: 'El sistema de pagos no está disponible actualmente',
|
||||
message: 'No se pueden procesar planes de pago en este momento. Por favor, intenta con el plan gratuito o contacta con soporte.'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Hashear contraseña y crear usuario
|
||||
const passwordHash = await bcrypt.hash(password, 10);
|
||||
await createUser({
|
||||
username,
|
||||
passwordHash,
|
||||
email: email || null,
|
||||
role: 'user',
|
||||
// Si es plan de pago, marcar como pendiente hasta que se complete el pago
|
||||
status: isPaidPlan ? 'pending_payment' : 'active',
|
||||
});
|
||||
|
||||
// Crear suscripción inicial
|
||||
const { updateUserSubscription } = await import('../services/mongodb.js');
|
||||
await updateUserSubscription(username, {
|
||||
planId: selectedPlanId,
|
||||
status: isPaidPlan ? 'pending' : 'active',
|
||||
currentPeriodStart: new Date(),
|
||||
currentPeriodEnd: null,
|
||||
cancelAtPeriodEnd: false,
|
||||
});
|
||||
|
||||
console.log(`✅ Usuario registrado: ${username} (${selectedPlanId}) - Estado: ${isPaidPlan ? 'pending_payment' : 'active'}`);
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Usuario creado correctamente',
|
||||
username,
|
||||
planId: selectedPlanId,
|
||||
requiresPayment: isPaidPlan,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error registrando usuario:', error);
|
||||
// Manejar error de duplicado
|
||||
if (error.code === 11000) {
|
||||
return res.status(409).json({ error: 'El usuario ya existe' });
|
||||
}
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Endpoint de login (público)
|
||||
router.post('/login', async (req, res) => {
|
||||
try {
|
||||
@@ -42,6 +135,23 @@ router.post('/login', async (req, res) => {
|
||||
return res.status(401).json({ error: 'Invalid credentials', message: 'Usuario o contraseña incorrectos' });
|
||||
}
|
||||
|
||||
// Verificar estado del usuario
|
||||
if (user.status === 'pending_payment') {
|
||||
return res.status(403).json({
|
||||
error: 'Payment pending',
|
||||
message: 'Tu cuenta está pendiente de pago. Por favor, completa el proceso de pago para activar tu cuenta.',
|
||||
status: 'pending_payment'
|
||||
});
|
||||
}
|
||||
|
||||
if (user.status === 'suspended' || user.status === 'disabled') {
|
||||
return res.status(403).json({
|
||||
error: 'Account suspended',
|
||||
message: 'Tu cuenta ha sido suspendida. Contacta con soporte.',
|
||||
status: user.status
|
||||
});
|
||||
}
|
||||
|
||||
// Generar fingerprint del dispositivo
|
||||
const { fingerprint, deviceInfo } = combineFingerprint(clientFingerprint, clientDeviceInfo, req);
|
||||
|
||||
@@ -266,5 +376,71 @@ router.delete('/:username', basicAuthMiddleware, adminAuthMiddleware, async (req
|
||||
}
|
||||
});
|
||||
|
||||
// Obtener usuarios activos/conectados (requiere autenticación)
|
||||
router.get('/active', basicAuthMiddleware, async (req, res) => {
|
||||
try {
|
||||
const db = getDB();
|
||||
if (!db) {
|
||||
return res.status(500).json({ error: 'MongoDB no está disponible' });
|
||||
}
|
||||
|
||||
// Obtener usuarios activos del WebSocket
|
||||
const activeUsersWS = getActiveUsers();
|
||||
|
||||
// Obtener sesiones activas de la base de datos
|
||||
const activeSessions = await getActiveSessions();
|
||||
|
||||
// Combinar información y eliminar duplicados
|
||||
const userMap = new Map();
|
||||
|
||||
// Añadir usuarios del WebSocket
|
||||
for (const user of activeUsersWS) {
|
||||
if (!userMap.has(user.username)) {
|
||||
userMap.set(user.username, {
|
||||
username: user.username,
|
||||
role: user.role,
|
||||
status: user.status,
|
||||
lastActivity: user.lastActivity,
|
||||
connectedViaWebSocket: true
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Añadir usuarios de sesiones activas (que podrían no estar en WebSocket)
|
||||
for (const session of activeSessions) {
|
||||
if (!userMap.has(session.username)) {
|
||||
const user = await getUser(session.username);
|
||||
userMap.set(session.username, {
|
||||
username: session.username,
|
||||
role: user?.role || 'user',
|
||||
status: 'active',
|
||||
lastActivity: session.lastActivity?.toISOString() || null,
|
||||
connectedViaWebSocket: false,
|
||||
deviceInfo: session.deviceInfo
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Convertir a array
|
||||
const activeUsers = Array.from(userMap.values());
|
||||
|
||||
// Ordenar por última actividad (más recientes primero)
|
||||
activeUsers.sort((a, b) => {
|
||||
const dateA = a.lastActivity ? new Date(a.lastActivity).getTime() : 0;
|
||||
const dateB = b.lastActivity ? new Date(b.lastActivity).getTime() : 0;
|
||||
return dateB - dateA;
|
||||
});
|
||||
|
||||
res.json({
|
||||
activeUsers,
|
||||
total: activeUsers.length,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error obteniendo usuarios activos:', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import express from 'express';
|
||||
import { basicAuthMiddleware } from '../middlewares/auth.js';
|
||||
import { broadcast } from '../services/websocket.js';
|
||||
import { getWorkers, setWorkers, getDB } from '../services/mongodb.js';
|
||||
import { checkSubscriptionLimits, checkWorkerLimit, checkPlatformAccess } from '../middlewares/subscriptionLimits.js';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
@@ -23,7 +24,7 @@ router.get('/', basicAuthMiddleware, async (req, res) => {
|
||||
});
|
||||
|
||||
// Actualizar workers del usuario autenticado (requiere autenticación)
|
||||
router.put('/', basicAuthMiddleware, async (req, res) => {
|
||||
router.put('/', basicAuthMiddleware, checkSubscriptionLimits, checkWorkerLimit, checkPlatformAccess, async (req, res) => {
|
||||
try {
|
||||
const db = getDB();
|
||||
if (!db) {
|
||||
|
||||
@@ -8,6 +8,7 @@ import { initVAPIDKeys } from './services/webPush.js';
|
||||
import { initWebSocket } from './services/websocket.js';
|
||||
import { startArticleMonitoring } from './services/articleMonitor.js';
|
||||
import { initFileWatcher } from './services/fileWatcher.js';
|
||||
import { initStripe } from './services/stripe.js';
|
||||
import routes from './routes/index.js';
|
||||
import workersRouter from './routes/workers.js';
|
||||
import articlesRouter from './routes/articles.js';
|
||||
@@ -18,6 +19,8 @@ import telegramRouter from './routes/telegram.js';
|
||||
import pushRouter from './routes/push.js';
|
||||
import usersRouter from './routes/users.js';
|
||||
import adminRouter from './routes/admin.js';
|
||||
import subscriptionRouter from './routes/subscription.js';
|
||||
import paymentsRouter from './routes/payments.js';
|
||||
|
||||
const app = express();
|
||||
const server = createServer(app);
|
||||
@@ -27,6 +30,12 @@ app.set('trust proxy', true);
|
||||
|
||||
// Middlewares globales
|
||||
app.use(cors());
|
||||
|
||||
// ⚠️ IMPORTANTE: Webhook de Stripe necesita el body RAW (sin parsear)
|
||||
// Por eso usamos express.raw() SOLO para esta ruta, ANTES de express.json()
|
||||
app.use('/api/payments/webhook', express.raw({ type: 'application/json' }));
|
||||
|
||||
// Ahora sí, parseamos JSON para todas las demás rutas
|
||||
app.use(express.json());
|
||||
|
||||
// Aplicar rate limiting a todas las rutas API
|
||||
@@ -35,6 +44,9 @@ app.use('/api', rateLimitMiddleware);
|
||||
// Inicializar VAPID keys para Web Push
|
||||
initVAPIDKeys();
|
||||
|
||||
// Inicializar Stripe
|
||||
initStripe();
|
||||
|
||||
// Inicializar WebSocket
|
||||
initWebSocket(server);
|
||||
|
||||
@@ -49,6 +61,8 @@ app.use('/api/telegram', telegramRouter);
|
||||
app.use('/api/push', pushRouter);
|
||||
app.use('/api/users', usersRouter);
|
||||
app.use('/api/admin', adminRouter);
|
||||
app.use('/api/subscription', subscriptionRouter);
|
||||
app.use('/api/payments', paymentsRouter);
|
||||
|
||||
// Inicializar servidor
|
||||
async function startServer() {
|
||||
|
||||
@@ -1101,6 +1101,30 @@ export async function getSession(token) {
|
||||
}
|
||||
}
|
||||
|
||||
// Actualizar actividad de una sesión
|
||||
export async function updateSessionActivity(token, isActive = true) {
|
||||
if (!db) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const sessionsCollection = db.collection('sessions');
|
||||
await sessionsCollection.updateOne(
|
||||
{ token },
|
||||
{
|
||||
$set: {
|
||||
lastActivity: new Date(),
|
||||
isActive: isActive,
|
||||
},
|
||||
}
|
||||
);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Error actualizando actividad de sesión:', error.message);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteSession(token) {
|
||||
if (!db) {
|
||||
return false;
|
||||
@@ -1139,21 +1163,63 @@ export async function getAllSessions() {
|
||||
try {
|
||||
const sessionsCollection = db.collection('sessions');
|
||||
const sessions = await sessionsCollection.find({}).toArray();
|
||||
return sessions.map(session => ({
|
||||
token: session.token,
|
||||
username: session.username,
|
||||
fingerprint: session.fingerprint || null,
|
||||
deviceInfo: session.deviceInfo || null,
|
||||
createdAt: session.createdAt,
|
||||
expiresAt: session.expiresAt,
|
||||
isExpired: session.expiresAt ? new Date(session.expiresAt) < new Date() : false,
|
||||
}));
|
||||
const now = new Date();
|
||||
const INACTIVE_TIMEOUT = 5 * 60 * 1000; // 5 minutos
|
||||
|
||||
return sessions.map(session => {
|
||||
const isExpired = session.expiresAt ? new Date(session.expiresAt) < now : false;
|
||||
const timeSinceActivity = session.lastActivity ? now - new Date(session.lastActivity) : null;
|
||||
const isActive = session.isActive && timeSinceActivity && timeSinceActivity < INACTIVE_TIMEOUT;
|
||||
|
||||
return {
|
||||
token: session.token,
|
||||
username: session.username,
|
||||
fingerprint: session.fingerprint || null,
|
||||
deviceInfo: session.deviceInfo || null,
|
||||
createdAt: session.createdAt,
|
||||
expiresAt: session.expiresAt,
|
||||
lastActivity: session.lastActivity || null,
|
||||
isActive: isActive || false,
|
||||
isExpired: isExpired,
|
||||
};
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error obteniendo todas las sesiones:', error.message);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// Obtener sesiones activas (usuarios conectados)
|
||||
export async function getActiveSessions() {
|
||||
if (!db) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
const sessionsCollection = db.collection('sessions');
|
||||
const now = new Date();
|
||||
const INACTIVE_TIMEOUT = 5 * 60 * 1000; // 5 minutos
|
||||
const recentTime = new Date(now - INACTIVE_TIMEOUT);
|
||||
|
||||
// Buscar sesiones activas (con actividad reciente)
|
||||
const sessions = await sessionsCollection.find({
|
||||
isActive: true,
|
||||
lastActivity: { $gte: recentTime },
|
||||
expiresAt: { $gt: now }
|
||||
}).toArray();
|
||||
|
||||
return sessions.map(session => ({
|
||||
username: session.username,
|
||||
lastActivity: session.lastActivity,
|
||||
deviceInfo: session.deviceInfo || null,
|
||||
fingerprint: session.fingerprint || null,
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error('Error obteniendo sesiones activas:', error.message);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// Funciones para artículos
|
||||
export async function getArticle(platform, id, currentUsername = null) {
|
||||
if (!db) {
|
||||
@@ -1277,6 +1343,105 @@ export async function updateArticleFavorite(platform, id, is_favorite, username)
|
||||
}
|
||||
}
|
||||
|
||||
// Funciones para suscripciones
|
||||
export async function getUserSubscription(username) {
|
||||
if (!db) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const usersCollection = db.collection('users');
|
||||
const user = await usersCollection.findOne({ username });
|
||||
|
||||
if (!user) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Si no tiene suscripción, retornar plan gratuito por defecto
|
||||
if (!user.subscription) {
|
||||
return {
|
||||
planId: 'free',
|
||||
status: 'active',
|
||||
currentPeriodStart: user.createdAt || new Date(),
|
||||
currentPeriodEnd: null, // Plan gratuito no expira
|
||||
cancelAtPeriodEnd: false,
|
||||
};
|
||||
}
|
||||
|
||||
return user.subscription;
|
||||
} catch (error) {
|
||||
console.error(`Error obteniendo suscripción de ${username}:`, error.message);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateUserSubscription(username, subscriptionData) {
|
||||
if (!db) {
|
||||
throw new Error('MongoDB no está disponible');
|
||||
}
|
||||
|
||||
try {
|
||||
const usersCollection = db.collection('users');
|
||||
|
||||
// Verificar que el usuario existe
|
||||
const user = await usersCollection.findOne({ username });
|
||||
if (!user) {
|
||||
throw new Error(`Usuario ${username} no existe`);
|
||||
}
|
||||
|
||||
// Actualizar suscripción
|
||||
await usersCollection.updateOne(
|
||||
{ username },
|
||||
{
|
||||
$set: {
|
||||
subscription: {
|
||||
planId: subscriptionData.planId || 'free',
|
||||
status: subscriptionData.status || 'active',
|
||||
billingPeriod: subscriptionData.billingPeriod || 'monthly',
|
||||
currentPeriodStart: subscriptionData.currentPeriodStart || new Date(),
|
||||
currentPeriodEnd: subscriptionData.currentPeriodEnd || null,
|
||||
cancelAtPeriodEnd: subscriptionData.cancelAtPeriodEnd || false,
|
||||
stripeCustomerId: subscriptionData.stripeCustomerId || null,
|
||||
stripeSubscriptionId: subscriptionData.stripeSubscriptionId || null,
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error(`Error actualizando suscripción de ${username}:`, error.message);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getWorkerCount(username) {
|
||||
if (!db) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
try {
|
||||
const workersCollection = db.collection('workers');
|
||||
const workersData = await workersCollection.findOne({ username });
|
||||
|
||||
if (!workersData || !workersData.items) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Contar solo workers activos (no deshabilitados)
|
||||
const activeWorkers = (workersData.items || []).filter(
|
||||
(item, index) => !(workersData.disabled || []).includes(index)
|
||||
);
|
||||
|
||||
return activeWorkers.length;
|
||||
} catch (error) {
|
||||
console.error(`Error contando workers de ${username}:`, error.message);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
// Cerrar conexión
|
||||
export async function closeMongoDB() {
|
||||
if (mongoClient) {
|
||||
|
||||
226
web/backend/services/stripe.js
Normal file
@@ -0,0 +1,226 @@
|
||||
import Stripe from 'stripe';
|
||||
import { SUBSCRIPTION_PLANS } from '../config/subscriptionPlans.js';
|
||||
|
||||
let stripeClient = null;
|
||||
|
||||
// Inicializar Stripe
|
||||
export function initStripe() {
|
||||
let stripeSecretKey = process.env.STRIPE_SECRET_KEY;
|
||||
|
||||
if (!stripeSecretKey) {
|
||||
stripeSecretKey = 'sk_test_51SrpOfH73CrYqhOp2NfijzzU07ADADmwigscMVdLGzKu9zA83dsrODhfsaY1X4EFTSihhIB0lVtDQ2HpeOfMWTur00YLuuktSL';
|
||||
}
|
||||
|
||||
if (!stripeSecretKey) {
|
||||
console.warn('⚠️ STRIPE_SECRET_KEY no configurado. Los pagos estarán deshabilitados.');
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
stripeClient = new Stripe(stripeSecretKey, {
|
||||
apiVersion: '2024-12-18.acacia',
|
||||
});
|
||||
console.log('✅ Stripe inicializado correctamente');
|
||||
return stripeClient;
|
||||
} catch (error) {
|
||||
console.error('Error inicializando Stripe:', error.message);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Obtener cliente de Stripe
|
||||
export function getStripeClient() {
|
||||
return stripeClient;
|
||||
}
|
||||
|
||||
// Crear sesión de checkout de Stripe
|
||||
export async function createCheckoutSession({ planId, billingPeriod, email, userId }) {
|
||||
if (!stripeClient) {
|
||||
throw new Error('Stripe no está configurado');
|
||||
}
|
||||
|
||||
const plan = SUBSCRIPTION_PLANS[planId];
|
||||
if (!plan) {
|
||||
throw new Error('Plan no válido');
|
||||
}
|
||||
|
||||
if (planId === 'free') {
|
||||
throw new Error('El plan gratuito no requiere pago');
|
||||
}
|
||||
|
||||
// Precio según período de facturación
|
||||
const priceAmount = billingPeriod === 'yearly' ? plan.price.yearly : plan.price.monthly;
|
||||
const priceCents = Math.round(priceAmount * 100); // Convertir a centavos
|
||||
|
||||
// Crear precio en Stripe (o usar precio existente si ya lo tienes)
|
||||
const priceId = await getOrCreatePrice(planId, billingPeriod, priceCents);
|
||||
|
||||
// URL de éxito y cancelación
|
||||
const baseUrl = process.env.BASE_URL || 'http://localhost';
|
||||
const successUrl = `${baseUrl}/dashboard/?session_id={CHECKOUT_SESSION_ID}&payment_success=true`;
|
||||
const cancelUrl = `${baseUrl}/dashboard/?payment_cancelled=true`;
|
||||
|
||||
// Crear sesión de checkout
|
||||
const session = await stripeClient.checkout.sessions.create({
|
||||
mode: 'subscription',
|
||||
line_items: [
|
||||
{
|
||||
price: priceId,
|
||||
quantity: 1,
|
||||
},
|
||||
],
|
||||
success_url: successUrl,
|
||||
cancel_url: cancelUrl,
|
||||
customer_email: email,
|
||||
client_reference_id: userId,
|
||||
metadata: {
|
||||
userId,
|
||||
planId,
|
||||
billingPeriod,
|
||||
},
|
||||
subscription_data: {
|
||||
metadata: {
|
||||
userId,
|
||||
planId,
|
||||
billingPeriod,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return session;
|
||||
}
|
||||
|
||||
// Obtener o crear precio en Stripe
|
||||
async function getOrCreatePrice(planId, billingPeriod, priceCents) {
|
||||
const plan = SUBSCRIPTION_PLANS[planId];
|
||||
|
||||
// Buscar precio existente (usando lookup_key)
|
||||
const lookupKey = `${planId}_${billingPeriod}`;
|
||||
|
||||
try {
|
||||
const prices = await stripeClient.prices.list({
|
||||
lookup_keys: [lookupKey],
|
||||
limit: 1,
|
||||
});
|
||||
|
||||
if (prices.data.length > 0) {
|
||||
return prices.data[0].id;
|
||||
}
|
||||
} catch (error) {
|
||||
console.log('Precio no encontrado, creando nuevo...');
|
||||
}
|
||||
|
||||
// Si no existe, crear producto y precio
|
||||
let product;
|
||||
try {
|
||||
// Buscar producto existente
|
||||
const products = await stripeClient.products.list({
|
||||
limit: 100,
|
||||
});
|
||||
product = products.data.find(p => p.metadata.planId === planId);
|
||||
|
||||
// Si no existe, crear producto
|
||||
if (!product) {
|
||||
product = await stripeClient.products.create({
|
||||
name: `Wallabicher ${plan.name}`,
|
||||
description: plan.description,
|
||||
metadata: {
|
||||
planId,
|
||||
},
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error creando/buscando producto:', error);
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Crear precio
|
||||
const price = await stripeClient.prices.create({
|
||||
product: product.id,
|
||||
unit_amount: priceCents,
|
||||
currency: 'eur',
|
||||
recurring: {
|
||||
interval: billingPeriod === 'yearly' ? 'year' : 'month',
|
||||
},
|
||||
lookup_key: lookupKey,
|
||||
metadata: {
|
||||
planId,
|
||||
billingPeriod,
|
||||
},
|
||||
});
|
||||
|
||||
return price.id;
|
||||
}
|
||||
|
||||
// Crear portal del cliente para gestionar suscripción
|
||||
export async function createCustomerPortalSession(customerId) {
|
||||
if (!stripeClient) {
|
||||
throw new Error('Stripe no está configurado');
|
||||
}
|
||||
|
||||
const baseUrl = process.env.BASE_URL || 'http://localhost';
|
||||
const returnUrl = `${baseUrl}/dashboard/`;
|
||||
|
||||
const session = await stripeClient.billingPortal.sessions.create({
|
||||
customer: customerId,
|
||||
return_url: returnUrl,
|
||||
});
|
||||
|
||||
return session;
|
||||
}
|
||||
|
||||
// Cancelar suscripción
|
||||
export async function cancelSubscription(subscriptionId) {
|
||||
if (!stripeClient) {
|
||||
throw new Error('Stripe no está configurado');
|
||||
}
|
||||
|
||||
// Cancelar al final del período
|
||||
const subscription = await stripeClient.subscriptions.update(subscriptionId, {
|
||||
cancel_at_period_end: true,
|
||||
});
|
||||
|
||||
return subscription;
|
||||
}
|
||||
|
||||
// Reactivar suscripción cancelada
|
||||
export async function reactivateSubscription(subscriptionId) {
|
||||
if (!stripeClient) {
|
||||
throw new Error('Stripe no está configurado');
|
||||
}
|
||||
|
||||
const subscription = await stripeClient.subscriptions.update(subscriptionId, {
|
||||
cancel_at_period_end: false,
|
||||
});
|
||||
|
||||
return subscription;
|
||||
}
|
||||
|
||||
// Obtener suscripción por ID
|
||||
export async function getSubscription(subscriptionId) {
|
||||
if (!stripeClient) {
|
||||
throw new Error('Stripe no está configurado');
|
||||
}
|
||||
|
||||
return await stripeClient.subscriptions.retrieve(subscriptionId);
|
||||
}
|
||||
|
||||
// Verificar webhook signature
|
||||
export function verifyWebhookSignature(payload, signature) {
|
||||
if (!stripeClient) {
|
||||
throw new Error('Stripe no está configurado');
|
||||
}
|
||||
|
||||
let webhookSecret = process.env.STRIPE_WEBHOOK_SECRET;
|
||||
|
||||
if (!webhookSecret) {
|
||||
webhookSecret = 'whsec_n6tTKRSG38WJQDRX8jLjZTs7kPKxbdNP';
|
||||
}
|
||||
|
||||
if (!webhookSecret) {
|
||||
throw new Error('STRIPE_WEBHOOK_SECRET no configurado');
|
||||
}
|
||||
|
||||
return stripeClient.webhooks.constructEvent(payload, signature, webhookSecret);
|
||||
}
|
||||
|
||||
@@ -1,11 +1,17 @@
|
||||
import { WebSocketServer } from 'ws';
|
||||
import { getDB, getSession, getUser, deleteSession as deleteSessionFromDB } from './mongodb.js';
|
||||
import { getDB, getSession, getUser, deleteSession as deleteSessionFromDB, updateSessionActivity } from './mongodb.js';
|
||||
|
||||
let wss = null;
|
||||
|
||||
// Duración de la sesión en milisegundos (24 horas)
|
||||
const SESSION_DURATION = 24 * 60 * 60 * 1000;
|
||||
|
||||
// Tiempo de inactividad para marcar como inactivo (5 minutos)
|
||||
const INACTIVE_TIMEOUT = 5 * 60 * 1000;
|
||||
|
||||
// Intervalo para limpiar conexiones inactivas (1 minuto)
|
||||
const CLEANUP_INTERVAL = 60 * 1000;
|
||||
|
||||
// Inicializar WebSocket Server
|
||||
export function initWebSocket(server) {
|
||||
wss = new WebSocketServer({ server, path: '/ws' });
|
||||
@@ -73,18 +79,90 @@ export function initWebSocket(server) {
|
||||
role: user.role || 'user',
|
||||
token: token
|
||||
};
|
||||
ws.isAlive = true;
|
||||
ws.lastActivity = new Date();
|
||||
|
||||
// Actualizar estado de conexión en la base de datos
|
||||
await updateSessionActivity(token, true);
|
||||
|
||||
console.log(`Cliente WebSocket conectado: ${session.username} (${user.role || 'user'})`);
|
||||
|
||||
// Enviar confirmación de conexión
|
||||
ws.send(JSON.stringify({
|
||||
type: 'connection',
|
||||
status: 'connected',
|
||||
timestamp: new Date().toISOString()
|
||||
}));
|
||||
|
||||
// Broadcast a otros clientes que este usuario se conectó
|
||||
broadcastUserStatus(session.username, 'online');
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error validando token WebSocket:', error);
|
||||
ws.close(1011, 'Error de autenticación');
|
||||
return;
|
||||
}
|
||||
|
||||
ws.on('close', () => {
|
||||
// Manejar mensajes del cliente
|
||||
ws.on('message', async (data) => {
|
||||
try {
|
||||
const message = JSON.parse(data.toString());
|
||||
|
||||
// Manejar diferentes tipos de mensajes
|
||||
switch (message.type) {
|
||||
case 'ping':
|
||||
case 'heartbeat':
|
||||
// Actualizar actividad
|
||||
ws.isAlive = true;
|
||||
ws.lastActivity = new Date();
|
||||
|
||||
// Actualizar en base de datos
|
||||
if (ws.user && ws.user.token) {
|
||||
await updateSessionActivity(ws.user.token, true);
|
||||
}
|
||||
|
||||
// Responder con pong
|
||||
ws.send(JSON.stringify({
|
||||
type: 'pong',
|
||||
timestamp: new Date().toISOString()
|
||||
}));
|
||||
break;
|
||||
|
||||
case 'activity':
|
||||
// El usuario realizó alguna actividad (click, scroll, etc.)
|
||||
ws.isAlive = true;
|
||||
ws.lastActivity = new Date();
|
||||
|
||||
if (ws.user && ws.user.token) {
|
||||
await updateSessionActivity(ws.user.token, true);
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
console.log(`Mensaje WebSocket no manejado: ${message.type}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error procesando mensaje WebSocket:', error);
|
||||
}
|
||||
});
|
||||
|
||||
// Manejar pong nativo de WebSocket
|
||||
ws.on('pong', () => {
|
||||
ws.isAlive = true;
|
||||
ws.lastActivity = new Date();
|
||||
});
|
||||
|
||||
ws.on('close', async () => {
|
||||
if (ws.user) {
|
||||
console.log(`Cliente WebSocket desconectado: ${ws.user.username}`);
|
||||
|
||||
// Actualizar estado en base de datos
|
||||
if (ws.user.token) {
|
||||
await updateSessionActivity(ws.user.token, false);
|
||||
}
|
||||
|
||||
// Broadcast a otros clientes que este usuario se desconectó
|
||||
broadcastUserStatus(ws.user.username, 'offline');
|
||||
} else {
|
||||
console.log('Cliente WebSocket desconectado');
|
||||
}
|
||||
@@ -95,9 +173,81 @@ export function initWebSocket(server) {
|
||||
});
|
||||
});
|
||||
|
||||
// Iniciar limpieza periódica de conexiones inactivas
|
||||
startCleanupInterval();
|
||||
|
||||
return wss;
|
||||
}
|
||||
|
||||
// Limpiar conexiones inactivas periódicamente
|
||||
let cleanupIntervalId = null;
|
||||
|
||||
function startCleanupInterval() {
|
||||
if (cleanupIntervalId) {
|
||||
clearInterval(cleanupIntervalId);
|
||||
}
|
||||
|
||||
cleanupIntervalId = setInterval(() => {
|
||||
if (!wss) return;
|
||||
|
||||
const now = new Date();
|
||||
|
||||
wss.clients.forEach(async (ws) => {
|
||||
// Si el cliente no ha respondido, marcarlo como inactivo
|
||||
if (ws.isAlive === false) {
|
||||
console.log(`Cerrando conexión inactiva: ${ws.user?.username || 'desconocido'}`);
|
||||
|
||||
// Actualizar estado en base de datos antes de cerrar
|
||||
if (ws.user && ws.user.token) {
|
||||
await updateSessionActivity(ws.user.token, false);
|
||||
}
|
||||
|
||||
return ws.terminate();
|
||||
}
|
||||
|
||||
// Verificar si ha pasado el timeout de inactividad
|
||||
if (ws.lastActivity && (now - ws.lastActivity) > INACTIVE_TIMEOUT) {
|
||||
console.log(`Usuario inactivo detectado: ${ws.user?.username || 'desconocido'}`);
|
||||
|
||||
// Actualizar estado en base de datos pero mantener conexión
|
||||
if (ws.user && ws.user.token) {
|
||||
await updateSessionActivity(ws.user.token, false);
|
||||
}
|
||||
}
|
||||
|
||||
// Marcar como no vivo y enviar ping
|
||||
ws.isAlive = false;
|
||||
ws.ping();
|
||||
});
|
||||
}, CLEANUP_INTERVAL);
|
||||
}
|
||||
|
||||
// Detener limpieza
|
||||
function stopCleanupInterval() {
|
||||
if (cleanupIntervalId) {
|
||||
clearInterval(cleanupIntervalId);
|
||||
cleanupIntervalId = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Broadcast del estado de un usuario específico
|
||||
function broadcastUserStatus(username, status) {
|
||||
if (!wss) return;
|
||||
|
||||
const message = JSON.stringify({
|
||||
type: 'user_status',
|
||||
username: username,
|
||||
status: status, // 'online' | 'offline' | 'inactive'
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
|
||||
wss.clients.forEach((client) => {
|
||||
if (client.readyState === 1) { // WebSocket.OPEN
|
||||
client.send(message);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Broadcast a todos los clientes WebSocket
|
||||
export function broadcast(data) {
|
||||
if (!wss) return;
|
||||
@@ -114,3 +264,51 @@ export function getWebSocketServer() {
|
||||
return wss;
|
||||
}
|
||||
|
||||
// Obtener usuarios activos conectados
|
||||
export function getActiveUsers() {
|
||||
if (!wss) return [];
|
||||
|
||||
const activeUsers = [];
|
||||
const now = new Date();
|
||||
|
||||
wss.clients.forEach((ws) => {
|
||||
if (ws.user && ws.readyState === 1) { // WebSocket.OPEN
|
||||
const isActive = ws.lastActivity && (now - ws.lastActivity) <= INACTIVE_TIMEOUT;
|
||||
|
||||
activeUsers.push({
|
||||
username: ws.user.username,
|
||||
role: ws.user.role,
|
||||
status: isActive ? 'active' : 'inactive',
|
||||
lastActivity: ws.lastActivity?.toISOString() || null,
|
||||
connectedAt: ws.lastActivity?.toISOString() || null
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Eliminar duplicados (un usuario puede tener múltiples conexiones)
|
||||
const uniqueUsers = [];
|
||||
const seenUsernames = new Set();
|
||||
|
||||
for (const user of activeUsers) {
|
||||
if (!seenUsernames.has(user.username)) {
|
||||
seenUsernames.add(user.username);
|
||||
uniqueUsers.push(user);
|
||||
}
|
||||
}
|
||||
|
||||
return uniqueUsers;
|
||||
}
|
||||
|
||||
// Cerrar todas las conexiones y limpiar
|
||||
export function closeWebSocket() {
|
||||
stopCleanupInterval();
|
||||
|
||||
if (wss) {
|
||||
wss.clients.forEach((ws) => {
|
||||
ws.close(1001, 'Servidor cerrando');
|
||||
});
|
||||
wss.close();
|
||||
wss = null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -139,7 +139,7 @@ function parseUserAgent(userAgent) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Genera un fingerprint desde el frontend (cuando se envía desde el cliente)
|
||||
* Genera un fingerprint desde el dashboard (cuando se envía desde el cliente)
|
||||
* @param {string} fingerprintHash - Hash del fingerprint generado en el cliente
|
||||
* @param {Object} deviceInfo - Información del dispositivo del cliente
|
||||
* @param {Object} req - Request object de Express
|
||||
|
||||
29
web/dashboard/Dockerfile
Normal file
@@ -0,0 +1,29 @@
|
||||
FROM node:18-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copiar archivos de dependencias
|
||||
COPY package.json package-lock.json* ./
|
||||
|
||||
# Instalar dependencias
|
||||
RUN npm ci
|
||||
|
||||
# Copiar código fuente
|
||||
COPY . .
|
||||
|
||||
# Construir aplicación con base /dashboard/
|
||||
RUN npm run build
|
||||
|
||||
# Stage de producción - servir con nginx
|
||||
FROM nginx:alpine
|
||||
|
||||
# Copiar archivos construidos (ya incluyen el prefijo /dashboard en las rutas)
|
||||
COPY --from=builder /app/dist /usr/share/nginx/html/dashboard
|
||||
|
||||
# Copiar configuración de nginx (se puede sobrescribir con volumen)
|
||||
COPY nginx-dashboard.conf /etc/nginx/conf.d/default.conf
|
||||
|
||||
EXPOSE 80
|
||||
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
|
||||
25
web/dashboard/nginx-dashboard.conf
Normal file
@@ -0,0 +1,25 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name localhost;
|
||||
root /usr/share/nginx/html/dashboard;
|
||||
index index.html;
|
||||
|
||||
# Gzip compression
|
||||
gzip on;
|
||||
gzip_vary on;
|
||||
gzip_min_length 1024;
|
||||
gzip_types text/plain text/css text/xml text/javascript application/x-javascript application/xml+rss application/json application/javascript;
|
||||
|
||||
# Cache static assets ANTES de SPA routing
|
||||
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
try_files $uri =404;
|
||||
}
|
||||
|
||||
# SPA routing - todo desde la raíz del contenedor
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
}
|
||||
|
||||
25
web/dashboard/nginx.conf
Normal file
@@ -0,0 +1,25 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name localhost;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# Gzip compression
|
||||
gzip on;
|
||||
gzip_vary on;
|
||||
gzip_min_length 1024;
|
||||
gzip_types text/plain text/css text/xml text/javascript application/x-javascript application/xml+rss application/json application/javascript;
|
||||
|
||||
# Cache static assets ANTES de SPA routing
|
||||
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
try_files $uri =404;
|
||||
}
|
||||
|
||||
# SPA routing - todas las rutas con prefijo /dashboard/
|
||||
location /dashboard/ {
|
||||
try_files $uri $uri/ /dashboard/index.html;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
{
|
||||
"name": "wallabicher-frontend",
|
||||
"name": "wallabicher-dashboard",
|
||||
"version": "1.0.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "wallabicher-frontend",
|
||||
"name": "wallabicher-dashboard",
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"@fingerprintjs/fingerprintjs": "^5.0.1",
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "wallabicher-frontend",
|
||||
"name": "wallabicher-dashboard",
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 118 KiB After Width: | Height: | Size: 118 KiB |
|
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 2.4 KiB After Width: | Height: | Size: 2.4 KiB |
|
Before Width: | Height: | Size: 5.6 KiB After Width: | Height: | Size: 5.6 KiB |
|
Before Width: | Height: | Size: 39 KiB After Width: | Height: | Size: 39 KiB |
@@ -20,8 +20,8 @@ self.addEventListener('push', (event) => {
|
||||
let notificationData = {
|
||||
title: 'Wallabicher',
|
||||
body: 'Tienes nuevas notificaciones',
|
||||
icon: '/android-chrome-192x192.png',
|
||||
badge: '/android-chrome-192x192.png',
|
||||
icon: '/dashboard/android-chrome-192x192.png',
|
||||
badge: '/dashboard/android-chrome-192x192.png',
|
||||
tag: 'wallabicher-notification',
|
||||
requireInteraction: false,
|
||||
data: {}
|
||||
@@ -89,7 +89,7 @@ self.addEventListener('notificationclick', (event) => {
|
||||
} else {
|
||||
// Si no hay URL, abrir la app
|
||||
event.waitUntil(
|
||||
clients.openWindow('/')
|
||||
clients.openWindow('/dashboard/')
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div class="min-h-screen bg-gray-100 dark:bg-gray-900" :class="{ 'sidebar-collapsed': sidebarCollapsed }">
|
||||
<!-- Sidebar - Solo mostrar si no estamos en login -->
|
||||
<template v-if="$route.path !== '/login'">
|
||||
<!-- Sidebar - Solo mostrar si no estamos en login o register -->
|
||||
<template v-if="!isPublicRoute">
|
||||
<!-- Sidebar -->
|
||||
<aside
|
||||
class="fixed top-0 left-0 z-40 h-screen transition-all duration-300 bg-white dark:bg-gray-800 border-r border-gray-200 dark:border-gray-700 shadow-lg"
|
||||
@@ -83,9 +83,9 @@
|
||||
</template>
|
||||
|
||||
<!-- Main Content -->
|
||||
<div class="transition-all duration-300" :class="$route.path === '/login' ? '' : (sidebarCollapsed ? 'ml-20' : 'ml-64')">
|
||||
<!-- Header - Solo mostrar si no estamos en login -->
|
||||
<header v-if="$route.path !== '/login'" class="sticky top-0 z-30 bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700 shadow-sm">
|
||||
<div class="transition-all duration-300" :class="isPublicRoute ? '' : (sidebarCollapsed ? 'ml-20' : 'ml-64')">
|
||||
<!-- Header - Solo mostrar si no estamos en login o register -->
|
||||
<header v-if="!isPublicRoute" class="sticky top-0 z-30 bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700 shadow-sm">
|
||||
<div class="flex items-center justify-between h-16 px-6">
|
||||
<!-- Breadcrumbs -->
|
||||
<div class="flex items-center space-x-2">
|
||||
@@ -138,13 +138,13 @@
|
||||
</header>
|
||||
|
||||
<!-- Page Content -->
|
||||
<main :class="$route.path === '/login' ? '' : 'p-6 pb-20'">
|
||||
<main :class="isPublicRoute ? '' : 'p-6 pb-20'">
|
||||
<router-view />
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<!-- Footer Fixed - Solo mostrar si no estamos en login -->
|
||||
<footer v-if="$route.path !== '/login'" class="fixed bottom-0 left-0 right-0 z-30 border-t border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 py-3 shadow-sm" :class="sidebarCollapsed ? 'ml-20' : 'ml-64'">
|
||||
<!-- Footer Fixed - Solo mostrar si no estamos en login o register -->
|
||||
<footer v-if="!isPublicRoute" class="fixed bottom-0 left-0 right-0 z-30 border-t border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 py-3 shadow-sm" :class="sidebarCollapsed ? 'ml-20' : 'ml-64'">
|
||||
<div class="px-6">
|
||||
<p class="text-center text-sm text-gray-600 dark:text-gray-400">
|
||||
© {{ new Date().getFullYear() }} Wallabicher. Todos los derechos reservados.
|
||||
@@ -164,10 +164,12 @@ import {
|
||||
DocumentTextIcon,
|
||||
HeartIcon,
|
||||
Cog6ToothIcon,
|
||||
CogIcon,
|
||||
UserGroupIcon,
|
||||
DocumentMagnifyingGlassIcon,
|
||||
ShieldExclamationIcon,
|
||||
ClockIcon,
|
||||
CreditCardIcon,
|
||||
Bars3Icon,
|
||||
XMarkIcon,
|
||||
SunIcon,
|
||||
@@ -178,7 +180,7 @@ import {
|
||||
} from '@heroicons/vue/24/outline';
|
||||
import pushNotificationService from './services/pushNotifications';
|
||||
import authService from './services/auth';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useRouter, useRoute } from 'vue-router';
|
||||
import api from './services/api';
|
||||
import ToastContainer from './components/ToastContainer.vue';
|
||||
|
||||
@@ -187,13 +189,16 @@ const allNavItems = [
|
||||
{ path: '/articles', name: 'Artículos', icon: DocumentTextIcon, adminOnly: false },
|
||||
{ path: '/favorites', name: 'Favoritos', icon: HeartIcon, adminOnly: false },
|
||||
{ path: '/workers', name: 'Workers', icon: Cog6ToothIcon, adminOnly: false },
|
||||
{ path: '/users', name: 'Usuarios', icon: UserGroupIcon, adminOnly: false },
|
||||
{ path: '/subscription', name: 'Suscripción', icon: CreditCardIcon, adminOnly: false },
|
||||
{ path: '/settings', name: 'Configuración', icon: CogIcon, adminOnly: false },
|
||||
{ path: '/users', name: 'Usuarios', icon: UserGroupIcon, adminOnly: true },
|
||||
{ path: '/logs', name: 'Logs', icon: DocumentMagnifyingGlassIcon, adminOnly: true },
|
||||
{ path: '/rate-limiter', name: 'Rate Limiter', icon: ShieldExclamationIcon, adminOnly: true },
|
||||
{ path: '/sessions', name: 'Sesiones', icon: ClockIcon, adminOnly: true },
|
||||
];
|
||||
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
const wsConnected = ref(false);
|
||||
const sidebarCollapsed = ref(false);
|
||||
const darkMode = ref(false);
|
||||
@@ -201,10 +206,16 @@ const pushEnabled = ref(false);
|
||||
const currentUser = ref(authService.getUsername() || null);
|
||||
const isAdmin = ref(false);
|
||||
let ws = null;
|
||||
let heartbeatInterval = null;
|
||||
let activityThrottleTimeout = null;
|
||||
|
||||
const isDark = computed(() => darkMode.value);
|
||||
const isAuthenticated = computed(() => authService.hasCredentials());
|
||||
|
||||
// Rutas públicas que no deben mostrar el sidebar/header
|
||||
const publicRoutes = ['/login', '/register'];
|
||||
const isPublicRoute = computed(() => publicRoutes.includes(route.path));
|
||||
|
||||
// Filtrar navItems según el rol del usuario
|
||||
const navItems = computed(() => {
|
||||
return allNavItems.filter(item => {
|
||||
@@ -326,6 +337,13 @@ onMounted(async () => {
|
||||
window.addEventListener('auth-login', handleAuthChange);
|
||||
window.addEventListener('auth-logout', handleAuthChange);
|
||||
|
||||
// Escuchar eventos de actividad del usuario para enviar al servidor
|
||||
// Estos eventos ayudan a mantener la sesión activa
|
||||
window.addEventListener('click', sendUserActivity);
|
||||
window.addEventListener('keydown', sendUserActivity);
|
||||
window.addEventListener('scroll', sendUserActivity, { passive: true });
|
||||
window.addEventListener('mousemove', sendUserActivity, { passive: true });
|
||||
|
||||
// Si hay credenciales, validar y conectar websocket
|
||||
if (authService.hasCredentials()) {
|
||||
// Validar si el token sigue siendo válido
|
||||
@@ -335,7 +353,7 @@ onMounted(async () => {
|
||||
authService.clearSession();
|
||||
currentUser.value = authService.getUsername() || null;
|
||||
isAdmin.value = authService.isAdmin();
|
||||
if (router.currentRoute.value.path !== '/login') {
|
||||
if (!publicRoutes.includes(router.currentRoute.value.path)) {
|
||||
router.push('/login');
|
||||
}
|
||||
} else {
|
||||
@@ -349,6 +367,15 @@ onUnmounted(() => {
|
||||
window.removeEventListener('auth-login', handleAuthChange);
|
||||
window.removeEventListener('auth-logout', handleAuthChange);
|
||||
|
||||
// Limpiar listeners de actividad
|
||||
window.removeEventListener('click', sendUserActivity);
|
||||
window.removeEventListener('keydown', sendUserActivity);
|
||||
window.removeEventListener('scroll', sendUserActivity);
|
||||
window.removeEventListener('mousemove', sendUserActivity);
|
||||
|
||||
// Detener heartbeat
|
||||
stopHeartbeat();
|
||||
|
||||
if (ws) {
|
||||
ws.close();
|
||||
}
|
||||
@@ -361,6 +388,12 @@ function connectWebSocket() {
|
||||
ws = null;
|
||||
}
|
||||
|
||||
// Limpiar heartbeat interval previo
|
||||
if (heartbeatInterval) {
|
||||
clearInterval(heartbeatInterval);
|
||||
heartbeatInterval = null;
|
||||
}
|
||||
|
||||
// Verificar si hay token de autenticación
|
||||
const token = authService.getToken();
|
||||
if (!token) {
|
||||
@@ -395,11 +428,17 @@ function connectWebSocket() {
|
||||
ws.onopen = () => {
|
||||
wsConnected.value = true;
|
||||
console.log('WebSocket conectado');
|
||||
|
||||
// Iniciar heartbeat cada 30 segundos
|
||||
startHeartbeat();
|
||||
};
|
||||
|
||||
ws.onclose = (event) => {
|
||||
wsConnected.value = false;
|
||||
|
||||
// Detener heartbeat
|
||||
stopHeartbeat();
|
||||
|
||||
// Si el cierre fue por autenticación fallida (código 1008), no reintentar
|
||||
if (event.code === 1008) {
|
||||
console.log('WebSocket cerrado: autenticación fallida');
|
||||
@@ -425,9 +464,73 @@ function connectWebSocket() {
|
||||
ws.onmessage = (event) => {
|
||||
const data = JSON.parse(event.data);
|
||||
|
||||
// Manejar respuesta de pong
|
||||
if (data.type === 'pong') {
|
||||
// El servidor respondió al heartbeat
|
||||
return;
|
||||
}
|
||||
|
||||
// Manejar confirmación de conexión
|
||||
if (data.type === 'connection' && data.status === 'connected') {
|
||||
console.log('Conexión WebSocket confirmada');
|
||||
return;
|
||||
}
|
||||
|
||||
// Manejar cambios de estado de usuarios
|
||||
if (data.type === 'user_status') {
|
||||
console.log(`Usuario ${data.username} cambió a estado: ${data.status}`);
|
||||
// Emitir evento para que otros componentes lo manejen
|
||||
window.dispatchEvent(new CustomEvent('user-status-change', { detail: data }));
|
||||
return;
|
||||
}
|
||||
|
||||
// Los componentes individuales manejarán los mensajes (incluyendo ToastContainer)
|
||||
window.dispatchEvent(new CustomEvent('ws-message', { detail: data }));
|
||||
};
|
||||
}
|
||||
|
||||
// Enviar heartbeat al servidor periódicamente
|
||||
function startHeartbeat() {
|
||||
// Detener heartbeat previo si existe
|
||||
stopHeartbeat();
|
||||
|
||||
// Enviar heartbeat cada 30 segundos
|
||||
heartbeatInterval = setInterval(() => {
|
||||
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({
|
||||
type: 'heartbeat',
|
||||
timestamp: new Date().toISOString()
|
||||
}));
|
||||
}
|
||||
}, 30000); // 30 segundos
|
||||
}
|
||||
|
||||
function stopHeartbeat() {
|
||||
if (heartbeatInterval) {
|
||||
clearInterval(heartbeatInterval);
|
||||
heartbeatInterval = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Enviar actividad del usuario al servidor (throttled)
|
||||
function sendUserActivity() {
|
||||
// Si ya hay un timeout pendiente, no hacer nada
|
||||
if (activityThrottleTimeout) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Enviar actividad y crear throttle de 10 segundos
|
||||
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({
|
||||
type: 'activity',
|
||||
timestamp: new Date().toISOString()
|
||||
}));
|
||||
}
|
||||
|
||||
// Throttle: no enviar más actividad por 10 segundos
|
||||
activityThrottleTimeout = setTimeout(() => {
|
||||
activityThrottleTimeout = null;
|
||||
}, 10000);
|
||||
}
|
||||
</script>
|
||||
|
||||
188
web/dashboard/src/components/ActiveUsers.vue
Normal file
@@ -0,0 +1,188 @@
|
||||
<template>
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-gray-100">
|
||||
Usuarios Activos
|
||||
</h3>
|
||||
<button
|
||||
@click="refreshUsers"
|
||||
:disabled="loading"
|
||||
class="p-2 rounded-lg text-gray-500 hover:text-gray-700 dark:hover:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors disabled:opacity-50"
|
||||
title="Actualizar"
|
||||
>
|
||||
<ArrowPathIcon
|
||||
class="w-5 h-5"
|
||||
:class="{ 'animate-spin': loading }"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Loading State -->
|
||||
<div v-if="loading && !activeUsers.length" class="text-center py-8">
|
||||
<div class="inline-block animate-spin rounded-full h-8 w-8 border-4 border-gray-200 border-t-primary-600"></div>
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">Cargando usuarios activos...</p>
|
||||
</div>
|
||||
|
||||
<!-- No Active Users -->
|
||||
<div v-else-if="!activeUsers.length" class="text-center py-8">
|
||||
<UsersIcon class="w-12 h-12 mx-auto text-gray-400 mb-2" />
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">No hay usuarios activos en este momento</p>
|
||||
</div>
|
||||
|
||||
<!-- Active Users List -->
|
||||
<div v-else class="space-y-2">
|
||||
<div
|
||||
v-for="user in activeUsers"
|
||||
:key="user.username"
|
||||
class="flex items-center justify-between p-3 rounded-lg bg-gray-50 dark:bg-gray-700/50 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
|
||||
>
|
||||
<div class="flex items-center space-x-3 flex-1 min-w-0">
|
||||
<!-- Status Indicator -->
|
||||
<div class="flex-shrink-0">
|
||||
<div
|
||||
class="w-3 h-3 rounded-full"
|
||||
:class="{
|
||||
'bg-green-500': user.status === 'active',
|
||||
'bg-yellow-500': user.status === 'inactive',
|
||||
'bg-gray-400': user.status !== 'active' && user.status !== 'inactive'
|
||||
}"
|
||||
:title="user.status === 'active' ? 'Activo' : 'Inactivo'"
|
||||
></div>
|
||||
</div>
|
||||
|
||||
<!-- User Info -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-sm font-medium text-gray-900 dark:text-gray-100 truncate">
|
||||
{{ user.username }}
|
||||
</p>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">
|
||||
<span v-if="user.lastActivity">
|
||||
Última actividad: {{ formatTime(user.lastActivity) }}
|
||||
</span>
|
||||
<span v-else>Sin actividad reciente</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Role Badge -->
|
||||
<div class="flex-shrink-0">
|
||||
<span
|
||||
class="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium"
|
||||
:class="{
|
||||
'bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-400': user.role === 'admin',
|
||||
'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400': user.role === 'user'
|
||||
}"
|
||||
>
|
||||
{{ user.role }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- WebSocket Indicator -->
|
||||
<div v-if="user.connectedViaWebSocket" class="flex-shrink-0" title="Conectado vía WebSocket">
|
||||
<SignalIcon class="w-4 h-4 text-green-500" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Stats -->
|
||||
<div v-if="activeUsers.length" class="mt-4 pt-4 border-t border-gray-200 dark:border-gray-700">
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 text-center">
|
||||
Total: {{ activeUsers.length }} {{ activeUsers.length === 1 ? 'usuario activo' : 'usuarios activos' }}
|
||||
<span v-if="lastUpdate"> • Actualizado {{ formatTime(lastUpdate) }}</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, onUnmounted } from 'vue';
|
||||
import { ArrowPathIcon, UsersIcon, SignalIcon } from '@heroicons/vue/24/outline';
|
||||
import api from '../services/api';
|
||||
|
||||
const activeUsers = ref([]);
|
||||
const loading = ref(false);
|
||||
const lastUpdate = ref(null);
|
||||
let refreshInterval = null;
|
||||
|
||||
async function refreshUsers() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const data = await api.getActiveUsers();
|
||||
activeUsers.value = data.activeUsers || [];
|
||||
lastUpdate.value = new Date().toISOString();
|
||||
} catch (error) {
|
||||
console.error('Error obteniendo usuarios activos:', error);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function formatTime(timestamp) {
|
||||
if (!timestamp) return '';
|
||||
|
||||
const date = new Date(timestamp);
|
||||
const now = new Date();
|
||||
const diffInSeconds = Math.floor((now - date) / 1000);
|
||||
|
||||
if (diffInSeconds < 60) {
|
||||
return 'hace unos segundos';
|
||||
} else if (diffInSeconds < 3600) {
|
||||
const minutes = Math.floor(diffInSeconds / 60);
|
||||
return `hace ${minutes} ${minutes === 1 ? 'minuto' : 'minutos'}`;
|
||||
} else if (diffInSeconds < 86400) {
|
||||
const hours = Math.floor(diffInSeconds / 3600);
|
||||
return `hace ${hours} ${hours === 1 ? 'hora' : 'horas'}`;
|
||||
} else {
|
||||
return date.toLocaleString('es-ES', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Actualizar cuando un usuario cambie de estado (via WebSocket)
|
||||
function handleUserStatusChange(event) {
|
||||
const { username, status } = event.detail;
|
||||
|
||||
// Buscar usuario en la lista
|
||||
const userIndex = activeUsers.value.findIndex(u => u.username === username);
|
||||
|
||||
if (status === 'online') {
|
||||
// Si es online y no está en la lista, añadirlo
|
||||
if (userIndex === -1) {
|
||||
refreshUsers(); // Recargar lista completa
|
||||
} else {
|
||||
// Actualizar estado
|
||||
activeUsers.value[userIndex].status = 'active';
|
||||
activeUsers.value[userIndex].lastActivity = new Date().toISOString();
|
||||
}
|
||||
} else if (status === 'offline') {
|
||||
// Si es offline, eliminarlo de la lista
|
||||
if (userIndex !== -1) {
|
||||
activeUsers.value.splice(userIndex, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
// Cargar usuarios activos inicialmente
|
||||
refreshUsers();
|
||||
|
||||
// Actualizar cada 30 segundos
|
||||
refreshInterval = setInterval(refreshUsers, 30000);
|
||||
|
||||
// Escuchar cambios de estado de usuarios
|
||||
window.addEventListener('user-status-change', handleUserStatusChange);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
if (refreshInterval) {
|
||||
clearInterval(refreshInterval);
|
||||
}
|
||||
window.removeEventListener('user-status-change', handleUserStatusChange);
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -7,35 +7,41 @@ import ArticleDetail from './views/ArticleDetail.vue';
|
||||
import Favorites from './views/Favorites.vue';
|
||||
import Workers from './views/Workers.vue';
|
||||
import Users from './views/Users.vue';
|
||||
import Settings from './views/Settings.vue';
|
||||
import Subscription from './views/Subscription.vue';
|
||||
import Logs from './views/Logs.vue';
|
||||
import RateLimiter from './views/RateLimiter.vue';
|
||||
import Sessions from './views/Sessions.vue';
|
||||
import Login from './views/Login.vue';
|
||||
import Register from './views/Register.vue';
|
||||
import './style.css';
|
||||
import authService from './services/auth';
|
||||
|
||||
const routes = [
|
||||
{ path: '/login', component: Login, name: 'login' },
|
||||
{ path: '/', component: Dashboard, meta: { requiresAuth: true } },
|
||||
{ path: '/register', component: Register, name: 'register' },
|
||||
{ path: '/', component: Dashboard, meta: { requiresAuth: true } }, // Redirige a /dashboard
|
||||
{ path: '/articles', component: Articles, meta: { requiresAuth: true } },
|
||||
{ path: '/articles/:platform/:id', component: ArticleDetail, meta: { requiresAuth: true } },
|
||||
{ path: '/favorites', component: Favorites, meta: { requiresAuth: true } },
|
||||
{ path: '/workers', component: Workers, meta: { requiresAuth: true } },
|
||||
{ path: '/users', component: Users, meta: { requiresAuth: true } },
|
||||
{ path: '/subscription', component: Subscription, meta: { requiresAuth: true } },
|
||||
{ path: '/settings', component: Settings, meta: { requiresAuth: true } },
|
||||
{ path: '/users', component: Users, meta: { requiresAuth: true, requiresAdmin: true } },
|
||||
{ path: '/logs', component: Logs, meta: { requiresAuth: true } },
|
||||
{ path: '/rate-limiter', component: RateLimiter, meta: { requiresAuth: true } },
|
||||
{ path: '/sessions', component: Sessions, meta: { requiresAuth: true } },
|
||||
];
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
history: createWebHistory('/dashboard'),
|
||||
routes,
|
||||
});
|
||||
|
||||
// Guard de navegación para verificar autenticación
|
||||
router.beforeEach(async (to, from, next) => {
|
||||
// Si la ruta es /login y ya está autenticado, redirigir al dashboard
|
||||
if (to.path === '/login') {
|
||||
// Si la ruta es /login o /register y ya está autenticado, redirigir al dashboard
|
||||
if (to.path === '/login' || to.path === '/register') {
|
||||
if (authService.hasCredentials()) {
|
||||
const isValid = await authService.validateSession();
|
||||
if (isValid) {
|
||||
@@ -47,6 +53,20 @@ router.beforeEach(async (to, from, next) => {
|
||||
return;
|
||||
}
|
||||
|
||||
// Si accede a la raíz y está autenticado, redirigir al dashboard
|
||||
if (to.path === '/') {
|
||||
if (authService.hasCredentials()) {
|
||||
const isValid = await authService.validateSession();
|
||||
if (isValid) {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
}
|
||||
// Si no está autenticado, redirigir a login
|
||||
next('/login');
|
||||
return;
|
||||
}
|
||||
|
||||
// Para todas las demás rutas, verificar autenticación
|
||||
if (to.meta.requiresAuth) {
|
||||
// Verificar si hay token almacenado
|
||||
@@ -63,6 +83,16 @@ router.beforeEach(async (to, from, next) => {
|
||||
next('/login');
|
||||
return;
|
||||
}
|
||||
|
||||
// Verificar si requiere permisos de administrador
|
||||
if (to.meta.requiresAdmin) {
|
||||
const isAdmin = authService.isAdmin();
|
||||
if (!isAdmin) {
|
||||
// No es admin, redirigir a dashboard (raíz relativa)
|
||||
next('/');
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Continuar la navegación
|
||||
@@ -77,8 +107,8 @@ app.mount('#app');
|
||||
if ('serviceWorker' in navigator) {
|
||||
window.addEventListener('load', async () => {
|
||||
try {
|
||||
const registration = await navigator.serviceWorker.register('/sw.js', {
|
||||
scope: '/'
|
||||
const registration = await navigator.serviceWorker.register('/dashboard/sw.js', {
|
||||
scope: '/dashboard/'
|
||||
});
|
||||
console.log('Service Worker registrado:', registration.scope);
|
||||
} catch (error) {
|
||||
@@ -155,6 +155,11 @@ export default {
|
||||
return response.data;
|
||||
},
|
||||
|
||||
async getActiveUsers() {
|
||||
const response = await api.get('/users/active');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Admin - Rate Limiter
|
||||
async getRateLimiterInfo() {
|
||||
const response = await api.get('/admin/rate-limiter');
|
||||
@@ -171,5 +176,48 @@ export default {
|
||||
const response = await api.delete(`/admin/sessions/${token}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Suscripciones
|
||||
async getSubscriptionPlans() {
|
||||
const response = await api.get('/subscription/plans');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
async getMySubscription() {
|
||||
const response = await api.get('/subscription/me');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
async updateSubscription(subscriptionData) {
|
||||
const response = await api.put('/subscription/me', subscriptionData);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Admin: Actualizar suscripción de otro usuario
|
||||
async updateUserSubscription(username, subscriptionData) {
|
||||
const response = await api.put(`/subscription/${username}`, subscriptionData);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Métodos genéricos para rutas adicionales
|
||||
async post(url, data) {
|
||||
const response = await api.post(url, data);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
async get(url, config) {
|
||||
const response = await api.get(url, config);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
async put(url, data) {
|
||||
const response = await api.put(url, data);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
async delete(url) {
|
||||
const response = await api.delete(url);
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -106,6 +106,11 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Active Users (Solo para admin) -->
|
||||
<div v-if="isAdmin">
|
||||
<ActiveUsers />
|
||||
</div>
|
||||
|
||||
<!-- Charts and Quick Actions -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<!-- Platform Distribution -->
|
||||
@@ -229,6 +234,7 @@
|
||||
import { ref, onMounted, onUnmounted } from 'vue';
|
||||
import api from '../services/api';
|
||||
import authService from '../services/auth';
|
||||
import ActiveUsers from '../components/ActiveUsers.vue';
|
||||
import {
|
||||
Cog6ToothIcon,
|
||||
HeartIcon,
|
||||
@@ -217,6 +217,12 @@
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="mt-8 pt-6 border-t border-gray-200 dark:border-gray-700">
|
||||
<p class="text-sm text-center text-gray-600 dark:text-gray-400 mb-2">
|
||||
¿No tienes cuenta?
|
||||
<router-link to="/register" class="text-primary-600 dark:text-primary-400 hover:underline font-semibold">
|
||||
Regístrate gratis
|
||||
</router-link>
|
||||
</p>
|
||||
<p class="text-xs text-center text-gray-500 dark:text-gray-400">
|
||||
¿Necesitas ayuda? Contacta con el administrador del sistema
|
||||
</p>
|
||||
@@ -265,7 +271,14 @@ async function handleLogin() {
|
||||
window.dispatchEvent(new CustomEvent('auth-login'));
|
||||
} catch (error) {
|
||||
console.error('Error en login:', error);
|
||||
loginError.value = error.message || 'Usuario o contraseña incorrectos';
|
||||
|
||||
// Verificar si el error es de pago pendiente
|
||||
if (error.response?.data?.status === 'pending_payment') {
|
||||
loginError.value = 'Tu cuenta está pendiente de pago. Por favor, completa el proceso de pago para activar tu cuenta. Si ya pagaste, espera unos minutos y vuelve a intentar.';
|
||||
} else {
|
||||
loginError.value = error.response?.data?.message || error.message || 'Usuario o contraseña incorrectos';
|
||||
}
|
||||
|
||||
authService.clearSession();
|
||||
} finally {
|
||||
loginLoading.value = false;
|
||||
394
web/dashboard/src/views/Register.vue
Normal file
@@ -0,0 +1,394 @@
|
||||
<template>
|
||||
<div class="fixed inset-0 bg-gradient-to-br from-primary-50 via-teal-50 to-cyan-50 dark:from-gray-950 dark:via-gray-900 dark:to-primary-950 overflow-y-auto">
|
||||
<div class="min-h-screen flex items-center justify-center p-4 py-12">
|
||||
<!-- Logo flotante en la esquina -->
|
||||
<div class="absolute top-4 left-4">
|
||||
<img src="/logo.jpg" alt="Wallabicher" class="w-12 h-12 rounded-xl shadow-lg" />
|
||||
</div>
|
||||
|
||||
<div class="w-full max-w-6xl">
|
||||
<!-- Header -->
|
||||
<div class="text-center mb-8">
|
||||
<h1 class="text-4xl sm:text-5xl font-bold text-gray-900 dark:text-white mb-2">
|
||||
Crea tu cuenta en
|
||||
<span class="bg-gradient-to-r from-primary-600 via-teal-600 to-cyan-600 bg-clip-text text-transparent">
|
||||
Wallabicher
|
||||
</span>
|
||||
</h1>
|
||||
<p class="text-lg text-gray-600 dark:text-gray-400 mb-2">
|
||||
Elige el plan perfecto para ti y empieza a monitorizar marketplaces
|
||||
</p>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-500 flex items-center justify-center gap-2">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 15l-2 5L9 9l11 4-5 2zm0 0l5 5M7.188 2.239l.777 2.897M5.136 7.965l-2.898-.777M13.95 4.05l-2.122 2.122m-5.657 5.656l-2.12 2.122"></path>
|
||||
</svg>
|
||||
Haz clic en un plan para seleccionarlo
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="grid lg:grid-cols-3 gap-6 mb-8">
|
||||
<!-- Planes -->
|
||||
<div
|
||||
v-for="plan in plans"
|
||||
:key="plan.id"
|
||||
@click="selectPlan(plan.id)"
|
||||
:class="[
|
||||
'group relative p-6 rounded-2xl border-2 cursor-pointer transition-all duration-300 transform hover:-translate-y-2 hover:shadow-xl',
|
||||
selectedPlan === plan.id
|
||||
? 'border-primary-500 dark:border-primary-400 shadow-2xl scale-105 bg-white dark:bg-gray-800 ring-4 ring-primary-100 dark:ring-primary-900/50'
|
||||
: 'border-gray-200 dark:border-gray-700 hover:border-primary-300 dark:hover:border-primary-700 bg-white/80 dark:bg-gray-800/80 hover:bg-white dark:hover:bg-gray-800'
|
||||
]"
|
||||
>
|
||||
<!-- Badge popular -->
|
||||
<div v-if="plan.id === 'basic'" class="absolute -top-3 left-1/2 -translate-x-1/2 px-3 py-1 bg-gradient-to-r from-primary-600 to-teal-600 text-white text-xs font-semibold rounded-full">
|
||||
Más Popular
|
||||
</div>
|
||||
|
||||
<!-- Checkmark si está seleccionado -->
|
||||
<div v-if="selectedPlan === plan.id" class="absolute top-4 right-4 w-8 h-8 bg-primary-600 rounded-full flex items-center justify-center shadow-lg animate-scale-in">
|
||||
<svg class="w-5 h-5 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<!-- Indicador de clickeable si NO está seleccionado -->
|
||||
<div v-else class="absolute top-4 right-4 w-8 h-8 border-2 border-gray-300 dark:border-gray-600 rounded-full flex items-center justify-center group-hover:border-primary-500 dark:group-hover:border-primary-400 transition-colors">
|
||||
<svg class="w-4 h-4 text-gray-400 group-hover:text-primary-500 transition-colors" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<div class="text-center mb-6">
|
||||
<h3 class="text-2xl font-bold text-gray-900 dark:text-white mb-2">{{ plan.name }}</h3>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400 mb-4">{{ plan.description }}</p>
|
||||
|
||||
<div class="mb-4">
|
||||
<div v-if="billingPeriod === 'monthly'">
|
||||
<span class="text-3xl font-bold text-gray-900 dark:text-white">
|
||||
€{{ plan.price.monthly.toFixed(2) }}
|
||||
</span>
|
||||
<span class="text-gray-600 dark:text-gray-400">/mes</span>
|
||||
</div>
|
||||
<div v-else>
|
||||
<span class="text-3xl font-bold text-gray-900 dark:text-white">
|
||||
€{{ plan.price.yearly.toFixed(2) }}
|
||||
</span>
|
||||
<span class="text-gray-600 dark:text-gray-400">/año</span>
|
||||
<div class="text-sm text-primary-600 dark:text-primary-400 mt-1">
|
||||
€{{ (plan.price.yearly / 12).toFixed(2) }}/mes
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ul class="space-y-2 mb-4">
|
||||
<li v-for="(feature, idx) in plan.features.slice(0, 4)" :key="idx" class="flex items-start text-sm">
|
||||
<svg class="w-5 h-5 text-primary-600 dark:text-primary-400 mr-2 mt-0.5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path>
|
||||
</svg>
|
||||
<span class="text-gray-700 dark:text-gray-300">{{ feature }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Formulario de registro -->
|
||||
<div class="max-w-md mx-auto bg-white dark:bg-gray-800 rounded-2xl shadow-2xl p-8 border border-gray-200 dark:border-gray-700">
|
||||
<h2 class="text-2xl font-bold text-gray-900 dark:text-white mb-2 text-center">
|
||||
Completa tu registro
|
||||
</h2>
|
||||
<div class="text-center mb-6 p-3 bg-primary-50 dark:bg-primary-900/20 rounded-lg border border-primary-200 dark:border-primary-800">
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">
|
||||
Plan seleccionado:
|
||||
<span class="font-bold text-primary-600 dark:text-primary-400">
|
||||
{{ plans.find(p => p.id === selectedPlan)?.name || 'Gratis' }}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Toggle mensual/anual -->
|
||||
<div class="flex justify-center mb-6">
|
||||
<div class="inline-flex items-center bg-gray-100 dark:bg-gray-700 rounded-lg p-1">
|
||||
<button
|
||||
@click="billingPeriod = 'monthly'"
|
||||
:class="[
|
||||
'px-4 py-2 rounded-md text-sm font-medium transition-all',
|
||||
billingPeriod === 'monthly'
|
||||
? 'bg-white dark:bg-gray-600 text-gray-900 dark:text-white shadow-sm'
|
||||
: 'text-gray-700 dark:text-gray-300'
|
||||
]"
|
||||
>
|
||||
Mensual
|
||||
</button>
|
||||
<button
|
||||
@click="billingPeriod = 'yearly'"
|
||||
:class="[
|
||||
'px-4 py-2 rounded-md text-sm font-medium transition-all',
|
||||
billingPeriod === 'yearly'
|
||||
? 'bg-white dark:bg-gray-600 text-gray-900 dark:text-white shadow-sm'
|
||||
: 'text-gray-700 dark:text-gray-300'
|
||||
]"
|
||||
>
|
||||
Anual
|
||||
<span class="ml-1 text-xs text-primary-600 dark:text-primary-400">-17%</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form @submit.prevent="handleRegister" class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Nombre de usuario
|
||||
</label>
|
||||
<input
|
||||
v-model="formData.username"
|
||||
type="text"
|
||||
required
|
||||
minlength="3"
|
||||
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
||||
placeholder="usuario123"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Email
|
||||
</label>
|
||||
<input
|
||||
v-model="formData.email"
|
||||
type="email"
|
||||
required
|
||||
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
||||
placeholder="tu@email.com"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Contraseña
|
||||
</label>
|
||||
<input
|
||||
v-model="formData.password"
|
||||
type="password"
|
||||
required
|
||||
minlength="6"
|
||||
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Confirmar contraseña
|
||||
</label>
|
||||
<input
|
||||
v-model="formData.confirmPassword"
|
||||
type="password"
|
||||
required
|
||||
minlength="6"
|
||||
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="error" class="bg-red-100 dark:bg-red-900/30 border border-red-400 dark:border-red-700 text-red-700 dark:text-red-400 px-4 py-3 rounded-lg text-sm">
|
||||
{{ error }}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
:disabled="loading"
|
||||
class="w-full py-3 px-4 bg-gradient-to-r from-primary-600 to-teal-600 hover:from-primary-700 hover:to-teal-700 text-white font-semibold rounded-lg shadow-lg hover:shadow-xl transition-all duration-300 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<span v-if="!loading">
|
||||
{{ selectedPlan === 'free' ? 'Crear cuenta gratis' : `Continuar al pago (€${getPlanPrice()})` }}
|
||||
</span>
|
||||
<span v-else>Procesando...</span>
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div class="mt-6 text-center">
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">
|
||||
¿Ya tienes cuenta?
|
||||
<router-link to="/login" class="text-primary-600 dark:text-primary-400 hover:underline font-semibold">
|
||||
Inicia sesión
|
||||
</router-link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { ref, computed, onMounted } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import api from '../services/api';
|
||||
|
||||
export default {
|
||||
name: 'Register',
|
||||
setup() {
|
||||
const router = useRouter();
|
||||
const plans = ref([]);
|
||||
const selectedPlan = ref('free');
|
||||
const billingPeriod = ref('monthly');
|
||||
const loading = ref(false);
|
||||
const error = ref('');
|
||||
|
||||
const formData = ref({
|
||||
username: '',
|
||||
email: '',
|
||||
password: '',
|
||||
confirmPassword: '',
|
||||
});
|
||||
|
||||
const getPlanPrice = () => {
|
||||
const plan = plans.value.find(p => p.id === selectedPlan.value);
|
||||
if (!plan) return '0.00';
|
||||
return billingPeriod.value === 'yearly'
|
||||
? plan.price.yearly.toFixed(2)
|
||||
: plan.price.monthly.toFixed(2);
|
||||
};
|
||||
|
||||
const selectPlan = (planId) => {
|
||||
selectedPlan.value = planId;
|
||||
error.value = '';
|
||||
};
|
||||
|
||||
const handleRegister = async () => {
|
||||
error.value = '';
|
||||
|
||||
// Validaciones
|
||||
if (formData.value.password !== formData.value.confirmPassword) {
|
||||
error.value = 'Las contraseñas no coinciden';
|
||||
return;
|
||||
}
|
||||
|
||||
if (formData.value.password.length < 6) {
|
||||
error.value = 'La contraseña debe tener al menos 6 caracteres';
|
||||
return;
|
||||
}
|
||||
|
||||
if (formData.value.username.length < 3) {
|
||||
error.value = 'El nombre de usuario debe tener al menos 3 caracteres';
|
||||
return;
|
||||
}
|
||||
|
||||
loading.value = true;
|
||||
|
||||
try {
|
||||
// 1. Registrar usuario
|
||||
const registerResponse = await api.post('/users/register', {
|
||||
username: formData.value.username,
|
||||
email: formData.value.email,
|
||||
password: formData.value.password,
|
||||
planId: selectedPlan.value,
|
||||
});
|
||||
|
||||
if (!registerResponse.success) {
|
||||
throw new Error(registerResponse.error || 'Error al registrar usuario');
|
||||
}
|
||||
|
||||
// 2. Si es plan gratuito, hacer login y redirigir al dashboard
|
||||
if (selectedPlan.value === 'free') {
|
||||
const loginResponse = await api.post('/users/login', {
|
||||
username: formData.value.username,
|
||||
password: formData.value.password,
|
||||
});
|
||||
|
||||
if (loginResponse.token) {
|
||||
localStorage.setItem('token', loginResponse.token);
|
||||
localStorage.setItem('username', loginResponse.username);
|
||||
localStorage.setItem('role', loginResponse.role || 'user');
|
||||
router.push('/');
|
||||
}
|
||||
} else {
|
||||
// 3. Si es plan de pago, crear sesión de checkout directamente (sin login)
|
||||
// El usuario se activará cuando se complete el pago
|
||||
try {
|
||||
const checkoutResponse = await api.post('/payments/create-checkout-session', {
|
||||
planId: selectedPlan.value,
|
||||
billingPeriod: billingPeriod.value,
|
||||
username: formData.value.username,
|
||||
email: formData.value.email,
|
||||
});
|
||||
|
||||
if (checkoutResponse.url) {
|
||||
// Guardar info para después del pago
|
||||
sessionStorage.setItem('pendingUsername', formData.value.username);
|
||||
|
||||
// Redirigir a Stripe Checkout
|
||||
window.location.href = checkoutResponse.url;
|
||||
} else {
|
||||
throw new Error('No se pudo crear la sesión de pago');
|
||||
}
|
||||
} catch (checkoutError) {
|
||||
// Si falla el checkout, mostrar error pero el usuario YA está creado
|
||||
console.error('Error creando sesión de checkout:', checkoutError);
|
||||
error.value = 'Usuario creado, pero hubo un error al procesar el pago. Por favor, intenta iniciar sesión y actualizar tu suscripción desde el panel.';
|
||||
|
||||
// Opcional: podríamos eliminar el usuario aquí si queremos ser más estrictos
|
||||
// Pero es mejor dejarlo para que pueda intentar pagar después desde el dashboard
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error en registro:', err);
|
||||
error.value = err.response?.data?.message || err.response?.data?.error || err.message || 'Error al registrar usuario';
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
// Cargar planes
|
||||
const response = await api.get('/subscription/plans');
|
||||
plans.value = response.plans || [];
|
||||
|
||||
// Preseleccionar el plan de la URL si existe
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const planFromUrl = urlParams.get('plan');
|
||||
if (planFromUrl && plans.value.find(p => p.id === planFromUrl)) {
|
||||
selectedPlan.value = planFromUrl;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error cargando planes:', err);
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
plans,
|
||||
selectedPlan,
|
||||
billingPeriod,
|
||||
formData,
|
||||
loading,
|
||||
error,
|
||||
selectPlan,
|
||||
handleRegister,
|
||||
getPlanPrice,
|
||||
};
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@keyframes scale-in {
|
||||
0% {
|
||||
transform: scale(0);
|
||||
opacity: 0;
|
||||
}
|
||||
50% {
|
||||
transform: scale(1.2);
|
||||
}
|
||||
100% {
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.animate-scale-in {
|
||||
animation: scale-in 0.3s ease-out;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
|
||||
<div v-else>
|
||||
<!-- Estadísticas -->
|
||||
<div v-if="sessionsData && sessionsData.stats" class="grid grid-cols-1 sm:grid-cols-4 gap-4 mb-6">
|
||||
<div v-if="sessionsData && sessionsData.stats" class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 gap-4 mb-6">
|
||||
<div class="card p-4">
|
||||
<div class="text-sm text-gray-600 dark:text-gray-400 mb-1">Total Sesiones</div>
|
||||
<div class="text-2xl font-bold text-gray-900 dark:text-gray-100">
|
||||
@@ -30,13 +30,25 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="card p-4">
|
||||
<div class="text-sm text-gray-600 dark:text-gray-400 mb-1">Sesiones Activas</div>
|
||||
<div class="text-sm text-gray-600 dark:text-gray-400 mb-1">Activas (Válidas)</div>
|
||||
<div class="text-2xl font-bold text-green-600 dark:text-green-400">
|
||||
{{ sessionsData.stats.active || 0 }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="card p-4">
|
||||
<div class="text-sm text-gray-600 dark:text-gray-400 mb-1">Sesiones Expiradas</div>
|
||||
<div class="text-sm text-gray-600 dark:text-gray-400 mb-1">🟢 Conectadas</div>
|
||||
<div class="text-2xl font-bold text-green-600 dark:text-green-400">
|
||||
{{ sessionsData.stats.connected || 0 }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="card p-4">
|
||||
<div class="text-sm text-gray-600 dark:text-gray-400 mb-1">🟡 Inactivas</div>
|
||||
<div class="text-2xl font-bold text-yellow-600 dark:text-yellow-400">
|
||||
{{ sessionsData.stats.inactive || 0 }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="card p-4">
|
||||
<div class="text-sm text-gray-600 dark:text-gray-400 mb-1">🔴 Expiradas</div>
|
||||
<div class="text-2xl font-bold text-red-600 dark:text-red-400">
|
||||
{{ sessionsData.stats.expired || 0 }}
|
||||
</div>
|
||||
@@ -79,6 +91,9 @@
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Dispositivo
|
||||
</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Última Actividad
|
||||
</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Token
|
||||
</th>
|
||||
@@ -118,6 +133,19 @@
|
||||
Sin información
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-4 py-3 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100">
|
||||
<div v-if="session.lastActivity" class="space-y-1">
|
||||
<div class="font-medium">
|
||||
{{ formatRelativeTime(session.lastActivity) }}
|
||||
</div>
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ formatDate(session.lastActivity) }}
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="text-gray-400 dark:text-gray-500 italic">
|
||||
Sin actividad
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm font-mono text-gray-600 dark:text-gray-400">
|
||||
<span class="truncate max-w-xs inline-block" :title="session.token">
|
||||
{{ session.token.substring(0, 16) }}...
|
||||
@@ -130,13 +158,26 @@
|
||||
{{ formatDate(session.expiresAt) }}
|
||||
</td>
|
||||
<td class="px-4 py-3 whitespace-nowrap">
|
||||
<span
|
||||
:class="session.isExpired
|
||||
? 'px-2 py-1 text-xs font-semibold rounded bg-red-100 dark:bg-red-900 text-red-800 dark:text-red-200'
|
||||
: 'px-2 py-1 text-xs font-semibold rounded bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-200'"
|
||||
>
|
||||
{{ session.isExpired ? 'Expirada' : 'Activa' }}
|
||||
</span>
|
||||
<div class="flex flex-col gap-1">
|
||||
<!-- Estado de sesión (válida o expirada) -->
|
||||
<span
|
||||
:class="session.isExpired
|
||||
? 'px-2 py-1 text-xs font-semibold rounded bg-red-100 dark:bg-red-900 text-red-800 dark:text-red-200'
|
||||
: 'px-2 py-1 text-xs font-semibold rounded bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-200'"
|
||||
>
|
||||
{{ session.isExpired ? '🔴 Expirada' : '✅ Válida' }}
|
||||
</span>
|
||||
|
||||
<!-- Estado de conexión (conectado o inactivo) -->
|
||||
<span
|
||||
v-if="!session.isExpired"
|
||||
:class="session.isActive
|
||||
? 'px-2 py-1 text-xs font-semibold rounded bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-200'
|
||||
: 'px-2 py-1 text-xs font-semibold rounded bg-yellow-100 dark:bg-yellow-900 text-yellow-800 dark:text-yellow-200'"
|
||||
>
|
||||
{{ session.isActive ? '🟢 Conectado' : '🟡 Inactivo' }}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-4 py-3 whitespace-nowrap text-sm">
|
||||
<button
|
||||
@@ -192,6 +233,27 @@ function formatDate(dateString) {
|
||||
});
|
||||
}
|
||||
|
||||
function formatRelativeTime(timestamp) {
|
||||
if (!timestamp) return '';
|
||||
|
||||
const date = new Date(timestamp);
|
||||
const now = new Date();
|
||||
const diffInSeconds = Math.floor((now - date) / 1000);
|
||||
|
||||
if (diffInSeconds < 60) {
|
||||
return 'Hace unos segundos';
|
||||
} else if (diffInSeconds < 3600) {
|
||||
const minutes = Math.floor(diffInSeconds / 60);
|
||||
return `Hace ${minutes} ${minutes === 1 ? 'minuto' : 'minutos'}`;
|
||||
} else if (diffInSeconds < 86400) {
|
||||
const hours = Math.floor(diffInSeconds / 3600);
|
||||
return `Hace ${hours} ${hours === 1 ? 'hora' : 'horas'}`;
|
||||
} else {
|
||||
const days = Math.floor(diffInSeconds / 86400);
|
||||
return `Hace ${days} ${days === 1 ? 'día' : 'días'}`;
|
||||
}
|
||||
}
|
||||
|
||||
function formatDeviceInfo(deviceInfo) {
|
||||
if (!deviceInfo) return 'Unknown';
|
||||
|
||||
314
web/dashboard/src/views/Settings.vue
Normal file
@@ -0,0 +1,314 @@
|
||||
<template>
|
||||
<div class="space-y-6">
|
||||
<!-- Page Header -->
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-gray-900 dark:text-gray-100">Configuración</h1>
|
||||
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
Gestiona tu configuración personal y preferencias
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Cambiar Contraseña -->
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3 class="card-title">Cambiar Contraseña</h3>
|
||||
<p class="card-subtitle">Actualiza tu contraseña de acceso</p>
|
||||
</div>
|
||||
|
||||
<form @submit.prevent="handleChangePassword" class="space-y-4">
|
||||
<div v-if="passwordError" class="bg-red-100 dark:bg-red-900/30 border border-red-400 dark:border-red-700 text-red-700 dark:text-red-300 px-4 py-3 rounded">
|
||||
{{ passwordError }}
|
||||
</div>
|
||||
|
||||
<div v-if="passwordSuccess" class="bg-green-100 dark:bg-green-900/30 border border-green-400 dark:border-green-700 text-green-700 dark:text-green-300 px-4 py-3 rounded">
|
||||
{{ passwordSuccess }}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Contraseña actual <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
v-model="passwordForm.currentPassword"
|
||||
type="password"
|
||||
class="input"
|
||||
placeholder="••••••••"
|
||||
required
|
||||
autocomplete="current-password"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Nueva contraseña <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
v-model="passwordForm.newPassword"
|
||||
type="password"
|
||||
class="input"
|
||||
placeholder="••••••••"
|
||||
required
|
||||
minlength="6"
|
||||
autocomplete="new-password"
|
||||
/>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||
Mínimo 6 caracteres
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Confirmar nueva contraseña <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
v-model="passwordForm.newPasswordConfirm"
|
||||
type="password"
|
||||
class="input"
|
||||
placeholder="••••••••"
|
||||
required
|
||||
minlength="6"
|
||||
autocomplete="new-password"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end gap-2 pt-4 border-t border-gray-200 dark:border-gray-700">
|
||||
<button
|
||||
type="submit"
|
||||
class="btn btn-primary"
|
||||
:disabled="loadingAction"
|
||||
>
|
||||
{{ loadingAction ? 'Cambiando...' : 'Cambiar Contraseña' }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Configuración de Telegram -->
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3 class="card-title">Configuración de Telegram</h3>
|
||||
<p class="card-subtitle">Configura tu bot de Telegram y canal para recibir notificaciones</p>
|
||||
</div>
|
||||
|
||||
<form @submit.prevent="saveTelegramConfig" class="space-y-4">
|
||||
<div v-if="telegramError" class="bg-red-100 dark:bg-red-900/30 border border-red-400 dark:border-red-700 text-red-700 dark:text-red-300 px-4 py-3 rounded">
|
||||
{{ telegramError }}
|
||||
</div>
|
||||
<div v-if="telegramSuccess" class="bg-green-100 dark:bg-green-900/30 border border-green-400 dark:border-green-700 text-green-700 dark:text-green-300 px-4 py-3 rounded">
|
||||
{{ telegramSuccess }}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Token del Bot <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
v-model="telegramForm.token"
|
||||
type="password"
|
||||
class="input"
|
||||
placeholder="Ej: 123456789:ABCdefGHIjklMNOpqrsTUVwxyz"
|
||||
required
|
||||
/>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||
Obtén tu token desde @BotFather en Telegram
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Canal o Grupo <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
v-model="telegramForm.channel"
|
||||
type="text"
|
||||
class="input"
|
||||
placeholder="Ej: @micanal o -1001234567890"
|
||||
required
|
||||
/>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||
Usa @nombrecanal para canales públicos o el ID numérico para grupos/canales privados
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center">
|
||||
<input
|
||||
v-model="telegramForm.enable_polling"
|
||||
type="checkbox"
|
||||
id="enable_polling"
|
||||
class="w-4 h-4 text-primary-600 rounded focus:ring-primary-500"
|
||||
/>
|
||||
<label for="enable_polling" class="ml-2 text-sm text-gray-700 dark:text-gray-300">
|
||||
Habilitar polling del bot (para comandos /favs, /threads, etc.)
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end gap-2 pt-4 border-t border-gray-200 dark:border-gray-700">
|
||||
<button
|
||||
type="submit"
|
||||
class="btn btn-primary"
|
||||
:disabled="loadingAction"
|
||||
>
|
||||
{{ loadingAction ? 'Guardando...' : 'Guardar Configuración' }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Información de la cuenta -->
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3 class="card-title">Información de la Cuenta</h3>
|
||||
<p class="card-subtitle">Datos de tu cuenta</p>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Nombre de usuario</p>
|
||||
<p class="text-base text-gray-900 dark:text-gray-100 font-semibold">{{ currentUser }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Rol</p>
|
||||
<span
|
||||
:class="isAdmin ? 'badge badge-warning' : 'badge badge-primary'"
|
||||
>
|
||||
{{ isAdmin ? 'Administrador' : 'Usuario' }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue';
|
||||
import api from '../services/api';
|
||||
import authService from '../services/auth';
|
||||
|
||||
const loadingAction = ref(false);
|
||||
const passwordError = ref('');
|
||||
const passwordSuccess = ref('');
|
||||
const telegramError = ref('');
|
||||
const telegramSuccess = ref('');
|
||||
|
||||
const passwordForm = ref({
|
||||
currentPassword: '',
|
||||
newPassword: '',
|
||||
newPasswordConfirm: '',
|
||||
});
|
||||
|
||||
const telegramForm = ref({
|
||||
token: '',
|
||||
channel: '',
|
||||
enable_polling: false
|
||||
});
|
||||
|
||||
const currentUser = computed(() => {
|
||||
return authService.getUsername() || '';
|
||||
});
|
||||
|
||||
const isAdmin = computed(() => {
|
||||
return authService.isAdmin();
|
||||
});
|
||||
|
||||
async function handleChangePassword() {
|
||||
passwordError.value = '';
|
||||
passwordSuccess.value = '';
|
||||
loadingAction.value = true;
|
||||
|
||||
if (!passwordForm.value.currentPassword || !passwordForm.value.newPassword || !passwordForm.value.newPasswordConfirm) {
|
||||
passwordError.value = 'Todos los campos son requeridos';
|
||||
loadingAction.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (passwordForm.value.newPassword.length < 6) {
|
||||
passwordError.value = 'La nueva contraseña debe tener al menos 6 caracteres';
|
||||
loadingAction.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (passwordForm.value.newPassword !== passwordForm.value.newPasswordConfirm) {
|
||||
passwordError.value = 'Las contraseñas no coinciden';
|
||||
loadingAction.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await api.changePassword({
|
||||
currentPassword: passwordForm.value.currentPassword,
|
||||
newPassword: passwordForm.value.newPassword,
|
||||
});
|
||||
|
||||
passwordSuccess.value = 'Contraseña actualizada correctamente. Por favor, inicia sesión nuevamente.';
|
||||
|
||||
// Invalidar la sesión actual - el usuario deberá hacer login nuevamente
|
||||
setTimeout(async () => {
|
||||
await authService.logout();
|
||||
// Recargar página para forzar nuevo login
|
||||
window.location.reload();
|
||||
}, 2000);
|
||||
} catch (error) {
|
||||
console.error('Error cambiando contraseña:', error);
|
||||
if (error.response?.data?.error) {
|
||||
passwordError.value = error.response.data.error;
|
||||
} else {
|
||||
passwordError.value = 'Error cambiando contraseña. Intenta de nuevo.';
|
||||
}
|
||||
} finally {
|
||||
loadingAction.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadTelegramConfig() {
|
||||
try {
|
||||
const config = await api.getTelegramConfig();
|
||||
if (config) {
|
||||
telegramForm.value = {
|
||||
token: config.token || '',
|
||||
channel: config.channel || '',
|
||||
enable_polling: config.enable_polling || false
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error cargando configuración de Telegram:', error);
|
||||
telegramError.value = 'Error cargando la configuración de Telegram';
|
||||
}
|
||||
}
|
||||
|
||||
async function saveTelegramConfig() {
|
||||
telegramError.value = '';
|
||||
telegramSuccess.value = '';
|
||||
|
||||
if (!telegramForm.value.token || !telegramForm.value.channel) {
|
||||
telegramError.value = 'Token y canal son requeridos';
|
||||
return;
|
||||
}
|
||||
|
||||
loadingAction.value = true;
|
||||
try {
|
||||
await api.setTelegramConfig(telegramForm.value);
|
||||
telegramSuccess.value = 'Configuración de Telegram guardada correctamente';
|
||||
setTimeout(() => {
|
||||
telegramSuccess.value = '';
|
||||
}, 3000);
|
||||
} catch (error) {
|
||||
console.error('Error guardando configuración de Telegram:', error);
|
||||
if (error.response?.data?.error) {
|
||||
telegramError.value = error.response.data.error;
|
||||
} else {
|
||||
telegramError.value = 'Error al guardar la configuración de Telegram';
|
||||
}
|
||||
} finally {
|
||||
loadingAction.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadTelegramConfig();
|
||||
});
|
||||
</script>
|
||||
|
||||
424
web/dashboard/src/views/Subscription.vue
Normal file
@@ -0,0 +1,424 @@
|
||||
<template>
|
||||
<div class="space-y-6">
|
||||
<!-- Page Header -->
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-gray-900 dark:text-gray-100">Suscripción</h1>
|
||||
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
Gestiona tu plan de suscripción y límites
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Current Plan Card -->
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3 class="card-title">Plan Actual</h3>
|
||||
<p class="card-subtitle">Tu plan de suscripción actual y uso</p>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="text-center py-8">
|
||||
<div class="inline-block animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600"></div>
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">Cargando información...</p>
|
||||
</div>
|
||||
|
||||
<div v-else-if="subscription && subscription.subscription && subscription.subscription.plan" class="space-y-6">
|
||||
<!-- Plan Info -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div class="p-6 bg-gradient-to-br from-primary-50 to-teal-50 dark:from-primary-900/20 dark:to-teal-900/20 rounded-xl border border-primary-200 dark:border-primary-800">
|
||||
<h4 class="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-2">Plan</h4>
|
||||
<p class="text-2xl font-bold text-gray-900 dark:text-gray-100">{{ subscription.subscription.plan?.name || 'Gratis' }}</p>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400 mt-1">{{ subscription.subscription.plan?.description || 'Plan gratuito' }}</p>
|
||||
</div>
|
||||
|
||||
<div class="p-6 bg-gradient-to-br from-gray-50 to-gray-100 dark:from-gray-800 dark:to-gray-700 rounded-xl border border-gray-200 dark:border-gray-700">
|
||||
<h4 class="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-2">Estado</h4>
|
||||
<span
|
||||
:class="subscription.subscription?.status === 'active' ? 'badge badge-success' : 'badge badge-warning'"
|
||||
class="text-lg font-semibold"
|
||||
>
|
||||
{{ subscription.subscription?.status === 'active' ? 'Activo' : 'Inactivo' }}
|
||||
</span>
|
||||
<p v-if="subscription.subscription?.currentPeriodEnd" class="text-sm text-gray-600 dark:text-gray-400 mt-2">
|
||||
Renovación: {{ formatDate(subscription.subscription.currentPeriodEnd) }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div v-if="subscription.subscription.planId !== 'free'" class="flex flex-wrap gap-3 pb-6 border-b border-gray-200 dark:border-gray-700">
|
||||
<button
|
||||
v-if="!subscription.subscription.cancelAtPeriodEnd"
|
||||
@click="openCancelModal"
|
||||
class="btn btn-outline-danger"
|
||||
:disabled="actionLoading"
|
||||
>
|
||||
Cancelar suscripción
|
||||
</button>
|
||||
<button
|
||||
v-else
|
||||
@click="reactivateSubscription"
|
||||
class="btn btn-primary"
|
||||
:disabled="actionLoading"
|
||||
>
|
||||
Reactivar suscripción
|
||||
</button>
|
||||
<button
|
||||
@click="openCustomerPortal"
|
||||
class="btn btn-outline"
|
||||
:disabled="actionLoading"
|
||||
>
|
||||
Gestionar pago
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Cancel Warning -->
|
||||
<div v-if="subscription.subscription.cancelAtPeriodEnd" class="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg p-4">
|
||||
<div class="flex items-start space-x-3">
|
||||
<svg class="w-5 h-5 text-yellow-600 dark:text-yellow-400 mt-0.5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"></path>
|
||||
</svg>
|
||||
<div>
|
||||
<p class="text-sm font-semibold text-yellow-800 dark:text-yellow-300">
|
||||
Suscripción cancelada
|
||||
</p>
|
||||
<p class="text-sm text-yellow-700 dark:text-yellow-400 mt-1">
|
||||
Tu suscripción se mantendrá activa hasta {{ formatDate(subscription.subscription.currentPeriodEnd) }}.
|
||||
Después se cambiará al plan gratuito automáticamente.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Usage Stats -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">Búsquedas activas</span>
|
||||
<span class="text-sm font-semibold text-gray-900 dark:text-gray-100">
|
||||
{{ subscription.usage?.workers || 0 }} / {{ subscription.usage?.maxWorkers || 'N/A' }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-2">
|
||||
<div
|
||||
class="bg-gradient-to-r from-primary-600 to-teal-600 h-2 rounded-full transition-all duration-300"
|
||||
:style="{ width: getUsagePercentage(subscription.usage?.workers || 0, subscription.usage?.maxWorkers) + '%' }"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 class="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-3">Plataformas disponibles</h4>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<span
|
||||
v-for="platform in (subscription.subscription.plan?.limits?.platforms || [])"
|
||||
:key="platform"
|
||||
class="badge badge-primary"
|
||||
>
|
||||
{{ platform }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Features -->
|
||||
<div>
|
||||
<h4 class="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-3">Características incluidas</h4>
|
||||
<ul class="grid grid-cols-1 md:grid-cols-2 gap-2">
|
||||
<li v-for="feature in (subscription.subscription.plan?.features || [])" :key="feature" class="flex items-start">
|
||||
<svg class="w-5 h-5 text-primary-600 dark:text-primary-400 mr-2 mt-0.5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path>
|
||||
</svg>
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">{{ feature }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="subscription && (!subscription.subscription || !subscription.subscription.plan)" class="text-center py-8">
|
||||
<p class="text-gray-600 dark:text-gray-400">No se pudo cargar la información del plan. Por favor, recarga la página.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Available Plans -->
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3 class="card-title">Planes Disponibles</h3>
|
||||
<p class="card-subtitle">Cambia tu plan en cualquier momento</p>
|
||||
</div>
|
||||
|
||||
<!-- Mensaje informativo -->
|
||||
<div class="mb-6 p-4 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg">
|
||||
<div class="flex items-start">
|
||||
<svg class="w-5 h-5 text-blue-600 dark:text-blue-400 mr-3 mt-0.5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
|
||||
</svg>
|
||||
<div>
|
||||
<h4 class="text-sm font-semibold text-blue-800 dark:text-blue-300 mb-1">Actualiza tu plan</h4>
|
||||
<p class="text-sm text-blue-700 dark:text-blue-400">
|
||||
Puedes cambiar a un plan superior en cualquier momento. Para planes de pago, serás redirigido a Stripe Checkout. El cambio a plan gratuito es inmediato.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="loadingPlans" class="text-center py-8">
|
||||
<div class="inline-block animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600"></div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="plans && plans.length > 0" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
<div
|
||||
v-for="plan in plans"
|
||||
:key="plan.id"
|
||||
class="p-6 rounded-xl border-2 transition-all duration-300 hover:shadow-xl"
|
||||
:class="
|
||||
plan.id === subscription?.subscription?.planId
|
||||
? 'border-primary-500 dark:border-primary-400 bg-primary-50 dark:bg-primary-900/20 shadow-lg'
|
||||
: 'border-gray-200 dark:border-gray-800 hover:border-primary-300 dark:hover:border-primary-700 hover:-translate-y-1'
|
||||
"
|
||||
>
|
||||
<!-- Badge de plan popular -->
|
||||
<div v-if="plan.id === 'basic'" class="absolute -top-3 left-1/2 -translate-x-1/2 px-3 py-1 bg-gradient-to-r from-primary-600 to-teal-600 text-white text-xs font-semibold rounded-full">
|
||||
Más Popular
|
||||
</div>
|
||||
|
||||
<div class="text-center mb-4">
|
||||
<h4 class="text-xl font-bold text-gray-900 dark:text-gray-100 mb-1">{{ plan.name }}</h4>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400 mb-4">{{ plan.description }}</p>
|
||||
<div class="mb-4">
|
||||
<span class="text-3xl font-bold text-gray-900 dark:text-gray-100">
|
||||
€{{ plan.price.monthly.toFixed(2) }}
|
||||
</span>
|
||||
<span class="text-gray-600 dark:text-gray-400">/mes</span>
|
||||
<p v-if="plan.price.yearly > 0" class="text-xs text-primary-600 dark:text-primary-400 mt-1">
|
||||
o €{{ plan.price.yearly.toFixed(2) }}/año (ahorra 17%)
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ul class="space-y-2 mb-6">
|
||||
<li v-for="feature in plan.features.slice(0, 4)" :key="feature" class="flex items-start text-sm">
|
||||
<svg class="w-4 h-4 text-primary-600 dark:text-primary-400 mr-2 mt-0.5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path>
|
||||
</svg>
|
||||
<span class="text-gray-700 dark:text-gray-300">{{ feature }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<button
|
||||
v-if="plan.id !== subscription?.subscription?.planId"
|
||||
@click="upgradePlan(plan.id)"
|
||||
:disabled="upgrading"
|
||||
class="w-full btn transition-all duration-300"
|
||||
:class="plan.id === 'free'
|
||||
? 'btn-outline'
|
||||
: 'btn-primary shadow-lg hover:shadow-xl'"
|
||||
>
|
||||
<span v-if="!upgrading">
|
||||
{{ plan.id === 'free' ? 'Cambiar a gratuito' : 'Seleccionar plan' }}
|
||||
</span>
|
||||
<span v-else class="flex items-center justify-center">
|
||||
<svg class="animate-spin -ml-1 mr-2 h-4 w-4" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
Procesando...
|
||||
</span>
|
||||
</button>
|
||||
<div v-else class="w-full text-center py-3 px-4 bg-gradient-to-r from-primary-100 to-teal-100 dark:from-primary-900/50 dark:to-teal-900/50 text-primary-700 dark:text-primary-400 rounded-lg font-semibold border-2 border-primary-300 dark:border-primary-700">
|
||||
✓ Plan Actual
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue';
|
||||
import api from '../services/api';
|
||||
|
||||
const subscription = ref(null);
|
||||
const plans = ref([]);
|
||||
const loading = ref(true);
|
||||
const loadingPlans = ref(true);
|
||||
const upgrading = ref(false);
|
||||
const actionLoading = ref(false);
|
||||
const showCancelModal = ref(false);
|
||||
|
||||
function formatDate(date) {
|
||||
if (!date) return 'N/A';
|
||||
return new Date(date).toLocaleDateString('es-ES', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
});
|
||||
}
|
||||
|
||||
function getUsagePercentage(current, max) {
|
||||
if (max === 'Ilimitado' || max === -1) return 0;
|
||||
if (typeof max === 'string') return 0;
|
||||
if (max === 0) return 0;
|
||||
return Math.min((current / max) * 100, 100);
|
||||
}
|
||||
|
||||
async function loadSubscription() {
|
||||
try {
|
||||
loading.value = true;
|
||||
const data = await api.getMySubscription();
|
||||
// Asegurar que la estructura de datos sea correcta
|
||||
if (data && !data.plan && data.subscription) {
|
||||
// Si viene la estructura del backend directamente
|
||||
subscription.value = data;
|
||||
} else if (data) {
|
||||
subscription.value = data;
|
||||
} else {
|
||||
subscription.value = null;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error cargando suscripción:', error);
|
||||
if (error.response?.status === 401) {
|
||||
// El usuario no está autenticado, se manejará automáticamente
|
||||
subscription.value = null;
|
||||
return;
|
||||
}
|
||||
subscription.value = null;
|
||||
// Mostrar error genérico (solo en consola, no interrumpir la UI)
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadPlans() {
|
||||
try {
|
||||
loadingPlans.value = true;
|
||||
const data = await api.getSubscriptionPlans();
|
||||
plans.value = data.plans || [];
|
||||
} catch (error) {
|
||||
console.error('Error cargando planes:', error);
|
||||
// Error al cargar planes (solo en consola)
|
||||
} finally {
|
||||
loadingPlans.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function upgradePlan(planId) {
|
||||
if (upgrading.value) return;
|
||||
|
||||
const selectedPlan = plans.value.find(p => p.id === planId);
|
||||
if (!selectedPlan) return;
|
||||
|
||||
// Si es plan de pago, crear sesión de checkout
|
||||
if (planId !== 'free') {
|
||||
// Preguntar período de facturación
|
||||
const billingPeriodChoice = confirm('¿Deseas facturación anual? (17% de descuento)\n\nAceptar = Anual\nCancelar = Mensual');
|
||||
const chosenBillingPeriod = billingPeriodChoice ? 'yearly' : 'monthly';
|
||||
|
||||
try {
|
||||
upgrading.value = true;
|
||||
const response = await api.post('/payments/create-checkout-session', {
|
||||
planId,
|
||||
billingPeriod: chosenBillingPeriod,
|
||||
});
|
||||
|
||||
if (response.url) {
|
||||
// Redirigir a Stripe Checkout
|
||||
window.location.href = response.url;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error creando sesión de checkout:', error);
|
||||
const errorMessage = error.response?.data?.message || error.response?.data?.error || 'Error al crear la sesión de pago';
|
||||
alert(errorMessage);
|
||||
} finally {
|
||||
upgrading.value = false;
|
||||
}
|
||||
} else {
|
||||
// Plan gratuito
|
||||
if (!confirm(`¿Estás seguro de que quieres cambiar al plan ${selectedPlan.name}?`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
upgrading.value = true;
|
||||
await api.updateSubscription({ planId });
|
||||
await loadSubscription();
|
||||
} catch (error) {
|
||||
console.error('Error actualizando plan:', error);
|
||||
const errorMessage = error.response?.data?.message || error.response?.data?.error || 'Error al actualizar el plan';
|
||||
alert(errorMessage);
|
||||
} finally {
|
||||
upgrading.value = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function openCancelModal() {
|
||||
if (confirm('¿Estás seguro de que quieres cancelar tu suscripción? Se mantendrá activa hasta el final del período de facturación.')) {
|
||||
cancelSubscription();
|
||||
}
|
||||
}
|
||||
|
||||
async function cancelSubscription() {
|
||||
try {
|
||||
actionLoading.value = true;
|
||||
await api.post('/payments/cancel-subscription');
|
||||
alert('Suscripción cancelada. Se mantendrá activa hasta el final del período.');
|
||||
await loadSubscription();
|
||||
} catch (error) {
|
||||
console.error('Error cancelando suscripción:', error);
|
||||
const errorMessage = error.response?.data?.message || error.response?.data?.error || 'Error al cancelar la suscripción';
|
||||
alert(errorMessage);
|
||||
} finally {
|
||||
actionLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function reactivateSubscription() {
|
||||
try {
|
||||
actionLoading.value = true;
|
||||
await api.post('/payments/reactivate-subscription');
|
||||
alert('Suscripción reactivada correctamente.');
|
||||
await loadSubscription();
|
||||
} catch (error) {
|
||||
console.error('Error reactivando suscripción:', error);
|
||||
const errorMessage = error.response?.data?.message || error.response?.data?.error || 'Error al reactivar la suscripción';
|
||||
alert(errorMessage);
|
||||
} finally {
|
||||
actionLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function openCustomerPortal() {
|
||||
try {
|
||||
actionLoading.value = true;
|
||||
const response = await api.post('/payments/create-portal-session');
|
||||
if (response.url) {
|
||||
window.location.href = response.url;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error abriendo portal de cliente:', error);
|
||||
const errorMessage = error.response?.data?.message || error.response?.data?.error || 'Error al abrir el portal de gestión';
|
||||
alert(errorMessage);
|
||||
} finally {
|
||||
actionLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadSubscription();
|
||||
loadPlans();
|
||||
|
||||
// Verificar si venimos de un pago exitoso
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
if (urlParams.get('payment_success') === 'true') {
|
||||
alert('¡Pago procesado exitosamente! Tu suscripción ha sido activada.');
|
||||
// Limpiar URL
|
||||
window.history.replaceState({}, document.title, window.location.pathname);
|
||||
} else if (urlParams.get('payment_cancelled') === 'true') {
|
||||
alert('Pago cancelado. Puedes intentarlo de nuevo cuando quieras.');
|
||||
// Limpiar URL
|
||||
window.history.replaceState({}, document.title, window.location.pathname);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
915
web/dashboard/src/views/Users.vue
Normal file
@@ -0,0 +1,915 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="flex flex-col sm:flex-row sm:justify-between sm:items-center gap-3 sm:gap-4 mb-4 sm:mb-6">
|
||||
<h1 class="text-2xl sm:text-3xl font-bold text-gray-900 dark:text-gray-100">Gestión de Usuarios</h1>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button
|
||||
v-if="isAdmin"
|
||||
@click="showAddModal = true"
|
||||
class="btn btn-primary text-xs sm:text-sm whitespace-nowrap"
|
||||
>
|
||||
+ Crear Usuario
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="!isAdmin" class="card text-center py-12">
|
||||
<p class="text-gray-600 dark:text-gray-400 text-lg font-semibold mb-2">Acceso Denegado</p>
|
||||
<p class="text-gray-500 dark:text-gray-500 text-sm">
|
||||
Solo los administradores pueden acceder a esta sección.
|
||||
</p>
|
||||
<router-link to="/settings" class="btn btn-primary mt-4 inline-block">
|
||||
Ir a Configuración
|
||||
</router-link>
|
||||
</div>
|
||||
|
||||
<div v-else-if="loading" class="text-center py-12">
|
||||
<div class="inline-block animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600"></div>
|
||||
<p class="mt-2 text-gray-600 dark:text-gray-400">Cargando usuarios...</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="space-y-4">
|
||||
<!-- Lista de usuarios -->
|
||||
<div v-if="users.length > 0" class="grid grid-cols-1 gap-4">
|
||||
<div
|
||||
v-for="user in users"
|
||||
:key="user.username"
|
||||
class="card hover:shadow-lg transition-shadow overflow-hidden"
|
||||
>
|
||||
<!-- Header con nombre y badges -->
|
||||
<div class="flex items-center justify-between mb-4 pb-4 border-b border-gray-200 dark:border-gray-700">
|
||||
<div class="flex items-center gap-3">
|
||||
<!-- Avatar -->
|
||||
<div class="w-12 h-12 rounded-full bg-gradient-to-br from-primary-500 to-primary-600 flex items-center justify-center text-white font-bold text-lg">
|
||||
{{ user.username.charAt(0).toUpperCase() }}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="flex items-center gap-2 mb-1">
|
||||
<h3 class="text-lg font-bold text-gray-900 dark:text-gray-100">{{ user.username }}</h3>
|
||||
<span
|
||||
v-if="user.username === currentUser"
|
||||
class="px-2 py-0.5 text-xs font-semibold rounded-full bg-blue-100 dark:bg-blue-900 text-blue-800 dark:text-blue-200"
|
||||
>
|
||||
Tú
|
||||
</span>
|
||||
<span
|
||||
v-if="user.role === 'admin'"
|
||||
class="px-2 py-0.5 text-xs font-semibold rounded-full bg-purple-100 dark:bg-purple-900 text-purple-800 dark:text-purple-200"
|
||||
>
|
||||
Admin
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Estado de conexión inline -->
|
||||
<div v-if="isAdmin" class="flex items-center gap-2">
|
||||
<span
|
||||
:class="{
|
||||
'px-2 py-0.5 text-xs font-semibold rounded-full bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-200': getUserConnectionStatus(user.username) === 'active',
|
||||
'px-2 py-0.5 text-xs font-semibold rounded-full bg-yellow-100 dark:bg-yellow-900 text-yellow-800 dark:text-yellow-200': getUserConnectionStatus(user.username) === 'inactive',
|
||||
'px-2 py-0.5 text-xs font-semibold rounded-full bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300': getUserConnectionStatus(user.username) === 'offline'
|
||||
}"
|
||||
>
|
||||
{{ getUserConnectionStatus(user.username) === 'active' ? '🟢 Conectado' : getUserConnectionStatus(user.username) === 'inactive' ? '🟡 Inactivo' : '⚫ Desconectado' }}
|
||||
</span>
|
||||
|
||||
<span v-if="getUserLastActivity(user.username)" class="text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ formatRelativeTime(getUserLastActivity(user.username)) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Botones de acción -->
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
v-if="isAdmin"
|
||||
@click="openSubscriptionModal(user.username)"
|
||||
class="btn btn-primary text-xs sm:text-sm"
|
||||
title="Gestionar suscripción"
|
||||
>
|
||||
💳 Plan
|
||||
</button>
|
||||
<button
|
||||
v-if="user.username !== currentUser && isAdmin"
|
||||
@click="confirmDeleteUser(user.username)"
|
||||
class="btn btn-danger text-xs sm:text-sm"
|
||||
title="Eliminar usuario"
|
||||
>
|
||||
🗑️ Eliminar
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Grid de información -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<!-- Columna izquierda: Información general -->
|
||||
<div class="space-y-3">
|
||||
<div>
|
||||
<h4 class="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase mb-2">Información</h4>
|
||||
<div class="space-y-2 text-sm">
|
||||
<div v-if="user.createdAt" class="flex justify-between">
|
||||
<span class="text-gray-600 dark:text-gray-400">Creado:</span>
|
||||
<span class="font-medium text-gray-900 dark:text-gray-100">{{ formatDate(user.createdAt) }}</span>
|
||||
</div>
|
||||
<div v-if="user.createdBy" class="flex justify-between">
|
||||
<span class="text-gray-600 dark:text-gray-400">Por:</span>
|
||||
<span class="font-medium text-gray-900 dark:text-gray-100">{{ user.createdBy }}</span>
|
||||
</div>
|
||||
<div v-if="user.updatedAt" class="flex justify-between">
|
||||
<span class="text-gray-600 dark:text-gray-400">Actualizado:</span>
|
||||
<span class="font-medium text-gray-900 dark:text-gray-100">{{ formatDate(user.updatedAt) }}</span>
|
||||
</div>
|
||||
<div v-if="isAdmin && getUserSessionCount(user.username) > 0" class="flex justify-between">
|
||||
<span class="text-gray-600 dark:text-gray-400">Sesiones activas:</span>
|
||||
<span class="font-semibold text-primary-600 dark:text-primary-400">{{ getUserSessionCount(user.username) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Columna derecha: Suscripción -->
|
||||
<div>
|
||||
<h4 class="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase mb-2">Suscripción</h4>
|
||||
<div v-if="userSubscriptions[user.username]" class="p-3 bg-gradient-to-br from-primary-50 to-teal-50 dark:from-primary-900/20 dark:to-teal-900/20 rounded-lg border border-primary-200 dark:border-primary-700">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<div>
|
||||
<p class="text-xs text-gray-600 dark:text-gray-400">Plan</p>
|
||||
<p class="text-lg font-bold text-primary-700 dark:text-primary-300">
|
||||
{{ userSubscriptions[user.username].subscription?.plan?.name || 'Gratis' }}
|
||||
</p>
|
||||
</div>
|
||||
<span
|
||||
:class="userSubscriptions[user.username].subscription?.status === 'active' ? 'badge badge-success' : 'badge badge-warning'"
|
||||
class="text-xs"
|
||||
>
|
||||
{{ userSubscriptions[user.username].subscription?.status === 'active' ? 'Activo' : 'Inactivo' }}
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="userSubscriptions[user.username].usage" class="pt-2 border-t border-primary-200 dark:border-primary-800">
|
||||
<div class="flex justify-between items-center">
|
||||
<span class="text-xs text-gray-600 dark:text-gray-400">Uso de búsquedas</span>
|
||||
<span class="text-sm font-semibold text-gray-900 dark:text-gray-100">
|
||||
{{ userSubscriptions[user.username].usage.workers }} / {{ userSubscriptions[user.username].usage.maxWorkers === 'Ilimitado' ? '∞' : userSubscriptions[user.username].usage.maxWorkers }}
|
||||
</span>
|
||||
</div>
|
||||
<!-- Barra de progreso -->
|
||||
<div v-if="userSubscriptions[user.username].usage.maxWorkers !== 'Ilimitado'" class="mt-2 w-full bg-gray-200 dark:bg-gray-700 rounded-full h-2">
|
||||
<div
|
||||
class="bg-gradient-to-r from-primary-500 to-primary-600 h-2 rounded-full transition-all"
|
||||
:style="{ width: `${Math.min(100, (userSubscriptions[user.username].usage.workers / userSubscriptions[user.username].usage.maxWorkers) * 100)}%` }"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="p-3 bg-gray-50 dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 text-center text-sm text-gray-500 dark:text-gray-400">
|
||||
Sin información de suscripción
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="card text-center py-12">
|
||||
<p class="text-gray-600 dark:text-gray-400 mb-4">No hay usuarios configurados</p>
|
||||
<button @click="showAddModal = true" class="btn btn-primary">
|
||||
+ Crear primer usuario
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal para crear/editar usuario -->
|
||||
<div
|
||||
v-if="showAddModal"
|
||||
class="fixed inset-0 bg-black bg-opacity-50 dark:bg-opacity-70 flex items-center justify-center z-50 p-4"
|
||||
@click.self="closeAddModal"
|
||||
>
|
||||
<div class="card max-w-md w-full max-h-[90vh] overflow-y-auto">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h2 class="text-xl font-bold text-gray-900 dark:text-gray-100">Crear Usuario</h2>
|
||||
<button
|
||||
@click="closeAddModal"
|
||||
class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
|
||||
title="Cerrar"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form @submit.prevent="handleCreateUser" class="space-y-4">
|
||||
<div v-if="addError" class="bg-red-100 dark:bg-red-900/30 border border-red-400 dark:border-red-700 text-red-700 dark:text-red-300 px-4 py-3 rounded">
|
||||
{{ addError }}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Nombre de usuario <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
v-model="userForm.username"
|
||||
type="text"
|
||||
class="input"
|
||||
placeholder="nuevo_usuario"
|
||||
required
|
||||
minlength="3"
|
||||
autocomplete="username"
|
||||
/>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||
Mínimo 3 caracteres
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Contraseña <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
v-model="userForm.password"
|
||||
type="password"
|
||||
class="input"
|
||||
placeholder="••••••••"
|
||||
required
|
||||
minlength="6"
|
||||
autocomplete="new-password"
|
||||
/>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||
Mínimo 6 caracteres
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Confirmar contraseña <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
v-model="userForm.passwordConfirm"
|
||||
type="password"
|
||||
class="input"
|
||||
placeholder="••••••••"
|
||||
required
|
||||
minlength="6"
|
||||
autocomplete="new-password"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col-reverse sm:flex-row justify-end gap-2 pt-4 border-t border-gray-200 dark:border-gray-700">
|
||||
<button
|
||||
type="button"
|
||||
@click="closeAddModal"
|
||||
class="btn btn-secondary text-sm sm:text-base"
|
||||
>
|
||||
Cancelar
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
class="btn btn-primary text-sm sm:text-base"
|
||||
:disabled="loadingAction"
|
||||
>
|
||||
{{ loadingAction ? 'Creando...' : 'Crear Usuario' }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- Modal para gestionar suscripción (solo admin) -->
|
||||
<div
|
||||
v-if="showSubscriptionModal && selectedUserForSubscription"
|
||||
class="fixed inset-0 bg-black bg-opacity-50 dark:bg-opacity-70 flex items-center justify-center z-50 p-4"
|
||||
@click.self="closeSubscriptionModal"
|
||||
>
|
||||
<div class="card max-w-2xl w-full max-h-[90vh] overflow-y-auto">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h2 class="text-xl font-bold text-gray-900 dark:text-gray-100">
|
||||
Gestionar Suscripción: {{ selectedUserForSubscription }}
|
||||
</h2>
|
||||
<button
|
||||
@click="closeSubscriptionModal"
|
||||
class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
|
||||
title="Cerrar"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="loadingSubscription" class="text-center py-8">
|
||||
<div class="inline-block animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600"></div>
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">Cargando información...</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="space-y-6">
|
||||
<!-- Información actual -->
|
||||
<div v-if="selectedUserSubscription && selectedUserSubscription.subscription" class="p-4 bg-gradient-to-br from-primary-50 to-teal-50 dark:from-primary-900/20 dark:to-teal-900/20 rounded-xl border border-primary-200 dark:border-primary-800">
|
||||
<h3 class="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-3">Plan Actual</h3>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<p class="text-xs text-gray-600 dark:text-gray-400">Plan</p>
|
||||
<p class="text-lg font-bold text-gray-900 dark:text-gray-100">
|
||||
{{ selectedUserSubscription.subscription?.plan?.name || 'Gratis' }}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs text-gray-600 dark:text-gray-400">Estado</p>
|
||||
<span
|
||||
:class="selectedUserSubscription.subscription?.status === 'active' ? 'badge badge-success' : 'badge badge-warning'"
|
||||
>
|
||||
{{ selectedUserSubscription.subscription?.status === 'active' ? 'Activo' : 'Inactivo' }}
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="selectedUserSubscription.usage">
|
||||
<p class="text-xs text-gray-600 dark:text-gray-400">Búsquedas</p>
|
||||
<p class="text-sm font-semibold text-gray-900 dark:text-gray-100">
|
||||
{{ selectedUserSubscription.usage.workers }} / {{ selectedUserSubscription.usage.maxWorkers === 'Ilimitado' ? '∞' : selectedUserSubscription.usage.maxWorkers }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Cambiar plan -->
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-3">Cambiar Plan</h3>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<button
|
||||
v-for="plan in availablePlans"
|
||||
:key="plan.id"
|
||||
@click="subscriptionForm.planId = plan.id"
|
||||
class="p-4 rounded-lg border-2 text-left transition-all"
|
||||
:class="
|
||||
subscriptionForm.planId === plan.id
|
||||
? 'border-primary-500 dark:border-primary-400 bg-primary-50 dark:bg-primary-900/20'
|
||||
: 'border-gray-200 dark:border-gray-700 hover:border-primary-300 dark:hover:border-primary-700'
|
||||
"
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="font-bold text-gray-900 dark:text-gray-100">{{ plan.name }}</p>
|
||||
<p class="text-xs text-gray-600 dark:text-gray-400 mt-1">{{ plan.description }}</p>
|
||||
</div>
|
||||
<div v-if="subscriptionForm.planId === plan.id" class="text-primary-600 dark:text-primary-400">
|
||||
✓
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Formulario de actualización -->
|
||||
<form @submit.prevent="handleUpdateUserSubscription" class="space-y-4">
|
||||
<div v-if="subscriptionError" class="bg-red-100 dark:bg-red-900/30 border border-red-400 dark:border-red-700 text-red-700 dark:text-red-300 px-4 py-3 rounded">
|
||||
{{ subscriptionError }}
|
||||
</div>
|
||||
|
||||
<div v-if="subscriptionSuccess" class="bg-green-100 dark:bg-green-900/30 border border-green-400 dark:border-green-700 text-green-700 dark:text-green-300 px-4 py-3 rounded">
|
||||
{{ subscriptionSuccess }}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Estado de la suscripción
|
||||
</label>
|
||||
<select
|
||||
v-model="subscriptionForm.status"
|
||||
class="input"
|
||||
>
|
||||
<option value="active">Activo</option>
|
||||
<option value="inactive">Inactivo</option>
|
||||
<option value="cancelled">Cancelado</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center">
|
||||
<input
|
||||
v-model="subscriptionForm.cancelAtPeriodEnd"
|
||||
type="checkbox"
|
||||
id="cancelAtPeriodEnd"
|
||||
class="w-4 h-4 text-primary-600 rounded focus:ring-primary-500"
|
||||
/>
|
||||
<label for="cancelAtPeriodEnd" class="ml-2 text-sm text-gray-700 dark:text-gray-300">
|
||||
Cancelar al final del período
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col-reverse sm:flex-row justify-end gap-2 pt-4 border-t border-gray-200 dark:border-gray-700">
|
||||
<button
|
||||
type="button"
|
||||
@click="closeSubscriptionModal"
|
||||
class="btn btn-secondary text-sm sm:text-base"
|
||||
>
|
||||
Cancelar
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
class="btn btn-primary text-sm sm:text-base"
|
||||
:disabled="loadingAction"
|
||||
>
|
||||
{{ loadingAction ? 'Actualizando...' : 'Actualizar Suscripción' }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal de confirmación para eliminar -->
|
||||
<div
|
||||
v-if="userToDelete"
|
||||
class="fixed inset-0 bg-black bg-opacity-50 dark:bg-opacity-70 flex items-center justify-center z-50 p-4"
|
||||
@click.self="userToDelete = null"
|
||||
>
|
||||
<div class="card max-w-md w-full">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h2 class="text-xl font-bold text-gray-900 dark:text-gray-100">Confirmar Eliminación</h2>
|
||||
<button
|
||||
@click="userToDelete = null"
|
||||
class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
|
||||
title="Cerrar"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p class="text-gray-700 dark:text-gray-300 mb-4">
|
||||
¿Estás seguro de que deseas eliminar al usuario <strong>{{ userToDelete }}</strong>?
|
||||
</p>
|
||||
<p class="text-sm text-red-600 dark:text-red-400 mb-4">
|
||||
Esta acción no se puede deshacer.
|
||||
</p>
|
||||
|
||||
<div class="flex flex-col-reverse sm:flex-row justify-end gap-2 pt-4 border-t border-gray-200 dark:border-gray-700">
|
||||
<button
|
||||
type="button"
|
||||
@click="userToDelete = null"
|
||||
class="btn btn-secondary text-sm sm:text-base"
|
||||
>
|
||||
Cancelar
|
||||
</button>
|
||||
<button
|
||||
@click="handleDeleteUser"
|
||||
class="btn btn-danger text-sm sm:text-base"
|
||||
:disabled="loadingAction"
|
||||
>
|
||||
{{ loadingAction ? 'Eliminando...' : 'Eliminar Usuario' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, onUnmounted, watch } from 'vue';
|
||||
import api from '../services/api';
|
||||
import authService from '../services/auth';
|
||||
|
||||
const users = ref([]);
|
||||
const loading = ref(true);
|
||||
const loadingAction = ref(false);
|
||||
const showAddModal = ref(false);
|
||||
const showSubscriptionModal = ref(false);
|
||||
const userToDelete = ref(null);
|
||||
const selectedUserForSubscription = ref(null);
|
||||
const loadingSubscription = ref(false);
|
||||
const userSubscriptions = ref({});
|
||||
const availablePlans = ref([]);
|
||||
const subscriptionError = ref('');
|
||||
const subscriptionSuccess = ref('');
|
||||
const addError = ref('');
|
||||
const activeUsers = ref([]);
|
||||
const userSessions = ref({});
|
||||
|
||||
const userForm = ref({
|
||||
username: '',
|
||||
password: '',
|
||||
passwordConfirm: '',
|
||||
});
|
||||
|
||||
const subscriptionForm = ref({
|
||||
planId: 'free',
|
||||
status: 'active',
|
||||
cancelAtPeriodEnd: false,
|
||||
});
|
||||
|
||||
const selectedUserSubscription = ref(null);
|
||||
|
||||
const isAuthenticated = computed(() => authService.hasCredentials());
|
||||
const currentUser = computed(() => {
|
||||
return authService.getUsername() || '';
|
||||
});
|
||||
const isAdmin = computed(() => {
|
||||
return authService.isAdmin();
|
||||
});
|
||||
|
||||
function formatDate(dateString) {
|
||||
if (!dateString) return 'N/A';
|
||||
try {
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleString('es-ES', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
} catch (e) {
|
||||
return dateString;
|
||||
}
|
||||
}
|
||||
|
||||
function formatRelativeTime(timestamp) {
|
||||
if (!timestamp) return 'Nunca';
|
||||
|
||||
const date = new Date(timestamp);
|
||||
const now = new Date();
|
||||
const diffInSeconds = Math.floor((now - date) / 1000);
|
||||
|
||||
if (diffInSeconds < 60) {
|
||||
return 'Hace unos segundos';
|
||||
} else if (diffInSeconds < 3600) {
|
||||
const minutes = Math.floor(diffInSeconds / 60);
|
||||
return `Hace ${minutes} ${minutes === 1 ? 'minuto' : 'minutos'}`;
|
||||
} else if (diffInSeconds < 86400) {
|
||||
const hours = Math.floor(diffInSeconds / 3600);
|
||||
return `Hace ${hours} ${hours === 1 ? 'hora' : 'horas'}`;
|
||||
} else {
|
||||
const days = Math.floor(diffInSeconds / 86400);
|
||||
return `Hace ${days} ${days === 1 ? 'día' : 'días'}`;
|
||||
}
|
||||
}
|
||||
|
||||
// Obtener el estado de conexión de un usuario
|
||||
function getUserConnectionStatus(username) {
|
||||
// Buscar si el usuario tiene alguna sesión activa
|
||||
const activeSessions = userSessions.value[username] || [];
|
||||
|
||||
if (activeSessions.length === 0) {
|
||||
return 'offline';
|
||||
}
|
||||
|
||||
// Verificar si alguna sesión está activamente conectada
|
||||
const hasActiveSession = activeSessions.some(session => session.isActive);
|
||||
if (hasActiveSession) {
|
||||
return 'active';
|
||||
}
|
||||
|
||||
// Si tiene sesiones válidas pero ninguna activa
|
||||
const hasValidSession = activeSessions.some(session => !session.isExpired);
|
||||
if (hasValidSession) {
|
||||
return 'inactive';
|
||||
}
|
||||
|
||||
return 'offline';
|
||||
}
|
||||
|
||||
// Obtener la última actividad de un usuario (de todas sus sesiones)
|
||||
function getUserLastActivity(username) {
|
||||
const sessions = userSessions.value[username] || [];
|
||||
|
||||
if (sessions.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Encontrar la sesión con la actividad más reciente
|
||||
let latestActivity = null;
|
||||
|
||||
for (const session of sessions) {
|
||||
if (session.lastActivity) {
|
||||
const activityDate = new Date(session.lastActivity);
|
||||
if (!latestActivity || activityDate > latestActivity) {
|
||||
latestActivity = activityDate;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return latestActivity ? latestActivity.toISOString() : null;
|
||||
}
|
||||
|
||||
// Obtener el número de sesiones activas de un usuario
|
||||
function getUserSessionCount(username) {
|
||||
const sessions = userSessions.value[username] || [];
|
||||
return sessions.filter(s => !s.isExpired).length;
|
||||
}
|
||||
|
||||
async function loadUsers() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const data = await api.getUsers();
|
||||
users.value = data.users || [];
|
||||
|
||||
// Cargar información de suscripción para todos los usuarios (solo si es admin)
|
||||
if (isAdmin.value) {
|
||||
await Promise.all([
|
||||
loadAllUserSubscriptions(),
|
||||
loadUserSessions(),
|
||||
]);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error cargando usuarios:', error);
|
||||
// El modal de login se manejará automáticamente desde App.vue
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadUserSessions() {
|
||||
if (!isAdmin.value) return;
|
||||
|
||||
try {
|
||||
// Obtener todas las sesiones
|
||||
const data = await api.getSessions();
|
||||
const sessions = data.sessions || [];
|
||||
|
||||
// Agrupar sesiones por usuario
|
||||
const sessionsByUser = {};
|
||||
for (const session of sessions) {
|
||||
if (!sessionsByUser[session.username]) {
|
||||
sessionsByUser[session.username] = [];
|
||||
}
|
||||
sessionsByUser[session.username].push(session);
|
||||
}
|
||||
|
||||
userSessions.value = sessionsByUser;
|
||||
} catch (error) {
|
||||
console.error('Error cargando sesiones de usuarios:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadAllUserSubscriptions() {
|
||||
// Cargar suscripciones de todos los usuarios en paralelo
|
||||
const subscriptionPromises = users.value.map(async (user) => {
|
||||
try {
|
||||
// Usar el endpoint de admin para obtener suscripción de cualquier usuario
|
||||
const response = await fetch(`/api/subscription/${user.username}`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Authorization': authService.getAuthHeader(),
|
||||
},
|
||||
});
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
console.log(`Suscripción de ${user.username}:`, data);
|
||||
userSubscriptions.value[user.username] = data;
|
||||
} else {
|
||||
console.warn(`No se pudo cargar suscripción de ${user.username}, usando valores por defecto`);
|
||||
// Valores por defecto si no se puede cargar
|
||||
userSubscriptions.value[user.username] = {
|
||||
subscription: {
|
||||
planId: 'free',
|
||||
status: 'active',
|
||||
plan: { name: 'Gratis', description: 'Plan gratuito' }
|
||||
},
|
||||
usage: { workers: 0, maxWorkers: 2 }
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error cargando suscripción de ${user.username}:`, error);
|
||||
// Valores por defecto en caso de error
|
||||
userSubscriptions.value[user.username] = {
|
||||
subscription: {
|
||||
planId: 'free',
|
||||
status: 'active',
|
||||
plan: { name: 'Gratis', description: 'Plan gratuito' }
|
||||
},
|
||||
usage: { workers: 0, maxWorkers: 2 }
|
||||
};
|
||||
}
|
||||
});
|
||||
await Promise.all(subscriptionPromises);
|
||||
}
|
||||
|
||||
async function loadAvailablePlans() {
|
||||
try {
|
||||
const data = await api.getSubscriptionPlans();
|
||||
availablePlans.value = data.plans || [];
|
||||
} catch (error) {
|
||||
console.error('Error cargando planes:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async function openSubscriptionModal(username) {
|
||||
selectedUserForSubscription.value = username;
|
||||
showSubscriptionModal.value = true;
|
||||
loadingSubscription.value = true;
|
||||
subscriptionError.value = '';
|
||||
subscriptionSuccess.value = '';
|
||||
|
||||
try {
|
||||
// Cargar planes disponibles
|
||||
await loadAvailablePlans();
|
||||
|
||||
// Cargar suscripción del usuario seleccionado
|
||||
const response = await fetch(`/api/subscription/${username}`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Authorization': authService.getAuthHeader(),
|
||||
},
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
selectedUserSubscription.value = data;
|
||||
subscriptionForm.value = {
|
||||
planId: data.subscription?.planId || 'free',
|
||||
status: data.subscription?.status || 'active',
|
||||
cancelAtPeriodEnd: data.subscription?.cancelAtPeriodEnd || false,
|
||||
};
|
||||
} else {
|
||||
// Si no hay suscripción, usar valores por defecto
|
||||
selectedUserSubscription.value = {
|
||||
subscription: {
|
||||
planId: 'free',
|
||||
status: 'active',
|
||||
plan: { name: 'Gratis', description: 'Plan gratuito' }
|
||||
},
|
||||
usage: { workers: 0, maxWorkers: 2 },
|
||||
};
|
||||
subscriptionForm.value = {
|
||||
planId: 'free',
|
||||
status: 'active',
|
||||
cancelAtPeriodEnd: false,
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error cargando suscripción:', error);
|
||||
subscriptionError.value = 'Error al cargar información de suscripción';
|
||||
} finally {
|
||||
loadingSubscription.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function closeSubscriptionModal() {
|
||||
showSubscriptionModal.value = false;
|
||||
selectedUserForSubscription.value = null;
|
||||
selectedUserSubscription.value = null;
|
||||
subscriptionError.value = '';
|
||||
subscriptionSuccess.value = '';
|
||||
subscriptionForm.value = {
|
||||
planId: 'free',
|
||||
status: 'active',
|
||||
cancelAtPeriodEnd: false,
|
||||
};
|
||||
}
|
||||
|
||||
async function handleUpdateUserSubscription() {
|
||||
if (!selectedUserForSubscription.value) return;
|
||||
|
||||
subscriptionError.value = '';
|
||||
subscriptionSuccess.value = '';
|
||||
loadingAction.value = true;
|
||||
|
||||
try {
|
||||
await api.updateUserSubscription(selectedUserForSubscription.value, {
|
||||
planId: subscriptionForm.value.planId,
|
||||
status: subscriptionForm.value.status,
|
||||
cancelAtPeriodEnd: subscriptionForm.value.cancelAtPeriodEnd,
|
||||
});
|
||||
|
||||
subscriptionSuccess.value = 'Suscripción actualizada correctamente';
|
||||
|
||||
// Actualizar la información en la lista
|
||||
await loadAllUserSubscriptions();
|
||||
|
||||
// Recargar la información del modal después de un momento
|
||||
setTimeout(async () => {
|
||||
await openSubscriptionModal(selectedUserForSubscription.value);
|
||||
}, 1000);
|
||||
} catch (error) {
|
||||
console.error('Error actualizando suscripción:', error);
|
||||
subscriptionError.value = error.response?.data?.error || 'Error al actualizar la suscripción';
|
||||
} finally {
|
||||
loadingAction.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCreateUser() {
|
||||
addError.value = '';
|
||||
loadingAction.value = true;
|
||||
|
||||
if (!userForm.value.username || !userForm.value.password || !userForm.value.passwordConfirm) {
|
||||
addError.value = 'Todos los campos son requeridos';
|
||||
loadingAction.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (userForm.value.username.length < 3) {
|
||||
addError.value = 'El nombre de usuario debe tener al menos 3 caracteres';
|
||||
loadingAction.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (userForm.value.password.length < 6) {
|
||||
addError.value = 'La contraseña debe tener al menos 6 caracteres';
|
||||
loadingAction.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (userForm.value.password !== userForm.value.passwordConfirm) {
|
||||
addError.value = 'Las contraseñas no coinciden';
|
||||
loadingAction.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await api.createUser({
|
||||
username: userForm.value.username,
|
||||
password: userForm.value.password,
|
||||
});
|
||||
|
||||
closeAddModal();
|
||||
await loadUsers();
|
||||
} catch (error) {
|
||||
console.error('Error creando usuario:', error);
|
||||
if (error.response?.data?.error) {
|
||||
addError.value = error.response.data.error;
|
||||
} else {
|
||||
addError.value = 'Error creando usuario. Intenta de nuevo.';
|
||||
}
|
||||
} finally {
|
||||
loadingAction.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
async function handleDeleteUser() {
|
||||
if (!userToDelete.value) return;
|
||||
|
||||
loadingAction.value = true;
|
||||
try {
|
||||
await api.deleteUser(userToDelete.value);
|
||||
userToDelete.value = null;
|
||||
await loadUsers();
|
||||
} catch (error) {
|
||||
console.error('Error eliminando usuario:', error);
|
||||
alert(error.response?.data?.error || 'Error eliminando usuario. Intenta de nuevo.');
|
||||
} finally {
|
||||
loadingAction.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function confirmDeleteUser(username) {
|
||||
userToDelete.value = username;
|
||||
}
|
||||
|
||||
function closeAddModal() {
|
||||
showAddModal.value = false;
|
||||
addError.value = '';
|
||||
userForm.value = {
|
||||
username: '',
|
||||
password: '',
|
||||
passwordConfirm: '',
|
||||
};
|
||||
}
|
||||
|
||||
function handleAuthLogout() {
|
||||
// Cuando el usuario se desconecta globalmente, limpiar datos
|
||||
users.value = [];
|
||||
showAddModal.value = false;
|
||||
userToDelete.value = null;
|
||||
addError.value = '';
|
||||
activeUsers.value = [];
|
||||
userSessions.value = {};
|
||||
}
|
||||
|
||||
// Manejar cambios de estado de usuarios vía WebSocket
|
||||
function handleUserStatusChange(event) {
|
||||
const { username, status } = event.detail;
|
||||
|
||||
// Recargar sesiones para actualizar el estado
|
||||
if (isAdmin.value) {
|
||||
loadUserSessions();
|
||||
}
|
||||
}
|
||||
|
||||
let refreshInterval = null;
|
||||
|
||||
onMounted(() => {
|
||||
loadUsers();
|
||||
window.addEventListener('auth-logout', handleAuthLogout);
|
||||
// Escuchar evento de login exitoso para recargar usuarios
|
||||
window.addEventListener('auth-login', () => {
|
||||
loadUsers();
|
||||
});
|
||||
|
||||
// Escuchar cambios de estado de usuarios
|
||||
window.addEventListener('user-status-change', handleUserStatusChange);
|
||||
|
||||
// Actualizar sesiones periódicamente (cada 30 segundos)
|
||||
if (isAdmin.value) {
|
||||
refreshInterval = setInterval(() => {
|
||||
loadUserSessions();
|
||||
}, 30000);
|
||||
}
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('auth-logout', handleAuthLogout);
|
||||
window.removeEventListener('auth-login', loadUsers);
|
||||
window.removeEventListener('user-status-change', handleUserStatusChange);
|
||||
|
||||
if (refreshInterval) {
|
||||
clearInterval(refreshInterval);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -2,8 +2,9 @@ import { defineConfig } from 'vite';
|
||||
import vue from '@vitejs/plugin-vue';
|
||||
import { fileURLToPath, URL } from 'url';
|
||||
|
||||
export default defineConfig({
|
||||
export default defineConfig(({ mode }) => ({
|
||||
plugins: [vue()],
|
||||
base: '/dashboard/',
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': fileURLToPath(new URL('./src', import.meta.url)),
|
||||
@@ -11,7 +12,9 @@ export default defineConfig({
|
||||
},
|
||||
server: {
|
||||
port: 3000,
|
||||
proxy: {
|
||||
host: true,
|
||||
// Proxy solo para desarrollo local
|
||||
proxy: mode === 'development' ? {
|
||||
'/api': {
|
||||
target: 'http://localhost:3001',
|
||||
changeOrigin: true,
|
||||
@@ -20,7 +23,7 @@ export default defineConfig({
|
||||
target: 'ws://localhost:3001',
|
||||
ws: true,
|
||||
},
|
||||
},
|
||||
} : undefined,
|
||||
},
|
||||
});
|
||||
}));
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name localhost;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# Gzip compression
|
||||
gzip on;
|
||||
gzip_vary on;
|
||||
gzip_min_length 1024;
|
||||
gzip_types text/plain text/css text/xml text/javascript application/x-javascript application/xml+rss application/json;
|
||||
|
||||
# SPA routing
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
# API proxy
|
||||
location /api {
|
||||
proxy_pass http://backend:3001;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection 'upgrade';
|
||||
proxy_set_header Host $host;
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
# WebSocket proxy
|
||||
location /ws {
|
||||
proxy_pass http://backend:3001;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
# Cache static assets
|
||||
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,720 +0,0 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="flex flex-col sm:flex-row sm:justify-between sm:items-center gap-3 sm:gap-4 mb-4 sm:mb-6">
|
||||
<h1 class="text-2xl sm:text-3xl font-bold text-gray-900 dark:text-gray-100">Gestión de Usuarios</h1>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button
|
||||
v-if="isAuthenticated"
|
||||
@click="showChangePasswordModal = true"
|
||||
class="btn btn-secondary text-xs sm:text-sm"
|
||||
>
|
||||
🔑 Cambiar Mi Contraseña
|
||||
</button>
|
||||
<button
|
||||
v-if="isAdmin"
|
||||
@click="showAddModal = true"
|
||||
class="btn btn-primary text-xs sm:text-sm whitespace-nowrap"
|
||||
>
|
||||
+ Crear Usuario
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="text-center py-12">
|
||||
<div class="inline-block animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600"></div>
|
||||
<p class="mt-2 text-gray-600 dark:text-gray-400">Cargando usuarios...</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="space-y-4">
|
||||
<!-- Lista de usuarios -->
|
||||
<div v-if="users.length > 0" class="grid grid-cols-1 gap-4">
|
||||
<div
|
||||
v-for="user in users"
|
||||
:key="user.username"
|
||||
class="card hover:shadow-lg transition-shadow"
|
||||
>
|
||||
<div class="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-3">
|
||||
<div class="flex-1">
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-gray-100">{{ user.username }}</h3>
|
||||
<span
|
||||
v-if="user.username === currentUser"
|
||||
class="px-2 py-1 text-xs font-semibold rounded bg-blue-100 dark:bg-blue-900 text-blue-800 dark:text-blue-200"
|
||||
>
|
||||
Tú
|
||||
</span>
|
||||
<span
|
||||
v-if="user.role === 'admin'"
|
||||
class="px-2 py-1 text-xs font-semibold rounded bg-purple-100 dark:bg-purple-900 text-purple-800 dark:text-purple-200"
|
||||
>
|
||||
Admin
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-2 text-sm text-gray-600 dark:text-gray-400">
|
||||
<div v-if="user.createdAt">
|
||||
<span class="font-medium">Creado:</span>
|
||||
<span class="ml-2">{{ formatDate(user.createdAt) }}</span>
|
||||
</div>
|
||||
<div v-if="user.createdBy">
|
||||
<span class="font-medium">Por:</span>
|
||||
<span class="ml-2">{{ user.createdBy }}</span>
|
||||
</div>
|
||||
<div v-if="user.updatedAt">
|
||||
<span class="font-medium">Actualizado:</span>
|
||||
<span class="ml-2">{{ formatDate(user.updatedAt) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
v-if="user.username === currentUser"
|
||||
@click="showTelegramModal = true"
|
||||
class="btn btn-secondary text-xs sm:text-sm"
|
||||
title="Configurar Telegram"
|
||||
>
|
||||
📱 Telegram
|
||||
</button>
|
||||
<button
|
||||
v-if="user.username === currentUser"
|
||||
@click="showChangePasswordModal = true"
|
||||
class="btn btn-secondary text-xs sm:text-sm"
|
||||
title="Cambiar contraseña"
|
||||
>
|
||||
🔑 Cambiar Contraseña
|
||||
</button>
|
||||
<button
|
||||
v-if="user.username !== currentUser && isAdmin"
|
||||
@click="confirmDeleteUser(user.username)"
|
||||
class="btn btn-danger text-xs sm:text-sm"
|
||||
title="Eliminar usuario"
|
||||
>
|
||||
🗑️ Eliminar
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="card text-center py-12">
|
||||
<p class="text-gray-600 dark:text-gray-400 mb-4">No hay usuarios configurados</p>
|
||||
<button @click="showAddModal = true" class="btn btn-primary">
|
||||
+ Crear primer usuario
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal para crear/editar usuario -->
|
||||
<div
|
||||
v-if="showAddModal"
|
||||
class="fixed inset-0 bg-black bg-opacity-50 dark:bg-opacity-70 flex items-center justify-center z-50 p-4"
|
||||
@click.self="closeAddModal"
|
||||
>
|
||||
<div class="card max-w-md w-full max-h-[90vh] overflow-y-auto">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h2 class="text-xl font-bold text-gray-900 dark:text-gray-100">Crear Usuario</h2>
|
||||
<button
|
||||
@click="closeAddModal"
|
||||
class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
|
||||
title="Cerrar"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form @submit.prevent="handleCreateUser" class="space-y-4">
|
||||
<div v-if="addError" class="bg-red-100 dark:bg-red-900/30 border border-red-400 dark:border-red-700 text-red-700 dark:text-red-300 px-4 py-3 rounded">
|
||||
{{ addError }}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Nombre de usuario <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
v-model="userForm.username"
|
||||
type="text"
|
||||
class="input"
|
||||
placeholder="nuevo_usuario"
|
||||
required
|
||||
minlength="3"
|
||||
autocomplete="username"
|
||||
/>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||
Mínimo 3 caracteres
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Contraseña <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
v-model="userForm.password"
|
||||
type="password"
|
||||
class="input"
|
||||
placeholder="••••••••"
|
||||
required
|
||||
minlength="6"
|
||||
autocomplete="new-password"
|
||||
/>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||
Mínimo 6 caracteres
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Confirmar contraseña <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
v-model="userForm.passwordConfirm"
|
||||
type="password"
|
||||
class="input"
|
||||
placeholder="••••••••"
|
||||
required
|
||||
minlength="6"
|
||||
autocomplete="new-password"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col-reverse sm:flex-row justify-end gap-2 pt-4 border-t border-gray-200 dark:border-gray-700">
|
||||
<button
|
||||
type="button"
|
||||
@click="closeAddModal"
|
||||
class="btn btn-secondary text-sm sm:text-base"
|
||||
>
|
||||
Cancelar
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
class="btn btn-primary text-sm sm:text-base"
|
||||
:disabled="loadingAction"
|
||||
>
|
||||
{{ loadingAction ? 'Creando...' : 'Crear Usuario' }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal para cambiar contraseña -->
|
||||
<div
|
||||
v-if="showChangePasswordModal"
|
||||
class="fixed inset-0 bg-black bg-opacity-50 dark:bg-opacity-70 flex items-center justify-center z-50 p-4"
|
||||
@click.self="closeChangePasswordModal"
|
||||
>
|
||||
<div class="card max-w-md w-full">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h2 class="text-xl font-bold text-gray-900 dark:text-gray-100">Cambiar Contraseña</h2>
|
||||
<button
|
||||
@click="closeChangePasswordModal"
|
||||
class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
|
||||
title="Cerrar"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form @submit.prevent="handleChangePassword" class="space-y-4">
|
||||
<div v-if="passwordError" class="bg-red-100 dark:bg-red-900/30 border border-red-400 dark:border-red-700 text-red-700 dark:text-red-300 px-4 py-3 rounded">
|
||||
{{ passwordError }}
|
||||
</div>
|
||||
|
||||
<div v-if="passwordSuccess" class="bg-green-100 dark:bg-green-900/30 border border-green-400 dark:border-green-700 text-green-700 dark:text-green-300 px-4 py-3 rounded">
|
||||
{{ passwordSuccess }}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Contraseña actual <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
v-model="passwordForm.currentPassword"
|
||||
type="password"
|
||||
class="input"
|
||||
placeholder="••••••••"
|
||||
required
|
||||
autocomplete="current-password"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Nueva contraseña <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
v-model="passwordForm.newPassword"
|
||||
type="password"
|
||||
class="input"
|
||||
placeholder="••••••••"
|
||||
required
|
||||
minlength="6"
|
||||
autocomplete="new-password"
|
||||
/>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||
Mínimo 6 caracteres
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Confirmar nueva contraseña <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
v-model="passwordForm.newPasswordConfirm"
|
||||
type="password"
|
||||
class="input"
|
||||
placeholder="••••••••"
|
||||
required
|
||||
minlength="6"
|
||||
autocomplete="new-password"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col-reverse sm:flex-row justify-end gap-2 pt-4 border-t border-gray-200 dark:border-gray-700">
|
||||
<button
|
||||
type="button"
|
||||
@click="closeChangePasswordModal"
|
||||
class="btn btn-secondary text-sm sm:text-base"
|
||||
>
|
||||
Cancelar
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
class="btn btn-primary text-sm sm:text-base"
|
||||
:disabled="loadingAction"
|
||||
>
|
||||
{{ loadingAction ? 'Cambiando...' : 'Cambiar Contraseña' }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal para configuración de Telegram -->
|
||||
<div
|
||||
v-if="showTelegramModal"
|
||||
class="fixed inset-0 bg-black bg-opacity-50 dark:bg-opacity-70 flex items-center justify-center z-50 p-2 sm:p-4"
|
||||
@click.self="closeTelegramModal"
|
||||
>
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg p-4 sm:p-6 max-w-2xl w-full max-h-[95vh] sm:max-h-[90vh] overflow-y-auto">
|
||||
<div class="flex items-center justify-between mb-3 sm:mb-4">
|
||||
<h2 class="text-xl sm:text-2xl font-bold text-gray-900 dark:text-gray-100">Configuración de Telegram</h2>
|
||||
<button
|
||||
@click="closeTelegramModal"
|
||||
class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
|
||||
title="Cerrar"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400 mb-4">
|
||||
Configura tu bot de Telegram y canal para recibir notificaciones de tus workers.
|
||||
</p>
|
||||
<form @submit.prevent="saveTelegramConfig" class="space-y-4">
|
||||
<div v-if="telegramError" class="bg-red-100 dark:bg-red-900/30 border border-red-400 dark:border-red-700 text-red-700 dark:text-red-300 px-4 py-3 rounded">
|
||||
{{ telegramError }}
|
||||
</div>
|
||||
<div v-if="telegramSuccess" class="bg-green-100 dark:bg-green-900/30 border border-green-400 dark:border-green-700 text-green-700 dark:text-green-300 px-4 py-3 rounded">
|
||||
{{ telegramSuccess }}
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Token del Bot <span class="text-red-500">*</span></label>
|
||||
<input
|
||||
v-model="telegramForm.token"
|
||||
type="password"
|
||||
class="input"
|
||||
placeholder="Ej: 123456789:ABCdefGHIjklMNOpqrsTUVwxyz"
|
||||
required
|
||||
/>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||
Obtén tu token desde @BotFather en Telegram
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Canal o Grupo <span class="text-red-500">*</span></label>
|
||||
<input
|
||||
v-model="telegramForm.channel"
|
||||
type="text"
|
||||
class="input"
|
||||
placeholder="Ej: @micanal o -1001234567890"
|
||||
required
|
||||
/>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||
Usa @nombrecanal para canales públicos o el ID numérico para grupos/canales privados
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<input
|
||||
v-model="telegramForm.enable_polling"
|
||||
type="checkbox"
|
||||
id="enable_polling"
|
||||
class="w-4 h-4 text-primary-600 rounded focus:ring-primary-500"
|
||||
/>
|
||||
<label for="enable_polling" class="ml-2 text-sm text-gray-700 dark:text-gray-300">
|
||||
Habilitar polling del bot (para comandos /favs, /threads, etc.)
|
||||
</label>
|
||||
</div>
|
||||
<div class="flex flex-col-reverse sm:flex-row justify-end gap-2 sm:space-x-2 pt-4 border-t border-gray-200 dark:border-gray-700">
|
||||
<button type="button" @click="closeTelegramModal" class="btn btn-secondary text-sm sm:text-base">
|
||||
Cancelar
|
||||
</button>
|
||||
<button type="submit" class="btn btn-primary text-sm sm:text-base" :disabled="loadingAction">
|
||||
{{ loadingAction ? 'Guardando...' : 'Guardar' }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal de confirmación para eliminar -->
|
||||
<div
|
||||
v-if="userToDelete"
|
||||
class="fixed inset-0 bg-black bg-opacity-50 dark:bg-opacity-70 flex items-center justify-center z-50 p-4"
|
||||
@click.self="userToDelete = null"
|
||||
>
|
||||
<div class="card max-w-md w-full">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h2 class="text-xl font-bold text-gray-900 dark:text-gray-100">Confirmar Eliminación</h2>
|
||||
<button
|
||||
@click="userToDelete = null"
|
||||
class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
|
||||
title="Cerrar"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p class="text-gray-700 dark:text-gray-300 mb-4">
|
||||
¿Estás seguro de que deseas eliminar al usuario <strong>{{ userToDelete }}</strong>?
|
||||
</p>
|
||||
<p class="text-sm text-red-600 dark:text-red-400 mb-4">
|
||||
Esta acción no se puede deshacer.
|
||||
</p>
|
||||
|
||||
<div class="flex flex-col-reverse sm:flex-row justify-end gap-2 pt-4 border-t border-gray-200 dark:border-gray-700">
|
||||
<button
|
||||
type="button"
|
||||
@click="userToDelete = null"
|
||||
class="btn btn-secondary text-sm sm:text-base"
|
||||
>
|
||||
Cancelar
|
||||
</button>
|
||||
<button
|
||||
@click="handleDeleteUser"
|
||||
class="btn btn-danger text-sm sm:text-base"
|
||||
:disabled="loadingAction"
|
||||
>
|
||||
{{ loadingAction ? 'Eliminando...' : 'Eliminar Usuario' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, onUnmounted, watch } from 'vue';
|
||||
import api from '../services/api';
|
||||
import authService from '../services/auth';
|
||||
|
||||
const users = ref([]);
|
||||
const loading = ref(true);
|
||||
const loadingAction = ref(false);
|
||||
const showAddModal = ref(false);
|
||||
const showChangePasswordModal = ref(false);
|
||||
const showTelegramModal = ref(false);
|
||||
const userToDelete = ref(null);
|
||||
const addError = ref('');
|
||||
const passwordError = ref('');
|
||||
const passwordSuccess = ref('');
|
||||
const telegramError = ref('');
|
||||
const telegramSuccess = ref('');
|
||||
|
||||
const userForm = ref({
|
||||
username: '',
|
||||
password: '',
|
||||
passwordConfirm: '',
|
||||
});
|
||||
|
||||
const passwordForm = ref({
|
||||
currentPassword: '',
|
||||
newPassword: '',
|
||||
newPasswordConfirm: '',
|
||||
});
|
||||
|
||||
const telegramForm = ref({
|
||||
token: '',
|
||||
channel: '',
|
||||
enable_polling: false
|
||||
});
|
||||
|
||||
const isAuthenticated = computed(() => authService.hasCredentials());
|
||||
const currentUser = computed(() => {
|
||||
return authService.getUsername() || '';
|
||||
});
|
||||
const isAdmin = computed(() => {
|
||||
return authService.isAdmin();
|
||||
});
|
||||
|
||||
function formatDate(dateString) {
|
||||
if (!dateString) return 'N/A';
|
||||
try {
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleString('es-ES', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
} catch (e) {
|
||||
return dateString;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadUsers() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const data = await api.getUsers();
|
||||
users.value = data.users || [];
|
||||
} catch (error) {
|
||||
console.error('Error cargando usuarios:', error);
|
||||
// El modal de login se manejará automáticamente desde App.vue
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCreateUser() {
|
||||
addError.value = '';
|
||||
loadingAction.value = true;
|
||||
|
||||
if (!userForm.value.username || !userForm.value.password || !userForm.value.passwordConfirm) {
|
||||
addError.value = 'Todos los campos son requeridos';
|
||||
loadingAction.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (userForm.value.username.length < 3) {
|
||||
addError.value = 'El nombre de usuario debe tener al menos 3 caracteres';
|
||||
loadingAction.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (userForm.value.password.length < 6) {
|
||||
addError.value = 'La contraseña debe tener al menos 6 caracteres';
|
||||
loadingAction.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (userForm.value.password !== userForm.value.passwordConfirm) {
|
||||
addError.value = 'Las contraseñas no coinciden';
|
||||
loadingAction.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await api.createUser({
|
||||
username: userForm.value.username,
|
||||
password: userForm.value.password,
|
||||
});
|
||||
|
||||
closeAddModal();
|
||||
await loadUsers();
|
||||
} catch (error) {
|
||||
console.error('Error creando usuario:', error);
|
||||
if (error.response?.data?.error) {
|
||||
addError.value = error.response.data.error;
|
||||
} else {
|
||||
addError.value = 'Error creando usuario. Intenta de nuevo.';
|
||||
}
|
||||
} finally {
|
||||
loadingAction.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleChangePassword() {
|
||||
passwordError.value = '';
|
||||
passwordSuccess.value = '';
|
||||
loadingAction.value = true;
|
||||
|
||||
if (!passwordForm.value.currentPassword || !passwordForm.value.newPassword || !passwordForm.value.newPasswordConfirm) {
|
||||
passwordError.value = 'Todos los campos son requeridos';
|
||||
loadingAction.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (passwordForm.value.newPassword.length < 6) {
|
||||
passwordError.value = 'La nueva contraseña debe tener al menos 6 caracteres';
|
||||
loadingAction.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (passwordForm.value.newPassword !== passwordForm.value.newPasswordConfirm) {
|
||||
passwordError.value = 'Las contraseñas no coinciden';
|
||||
loadingAction.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await api.changePassword({
|
||||
currentPassword: passwordForm.value.currentPassword,
|
||||
newPassword: passwordForm.value.newPassword,
|
||||
});
|
||||
|
||||
passwordSuccess.value = 'Contraseña actualizada correctamente. Por favor, inicia sesión nuevamente.';
|
||||
|
||||
// Invalidar la sesión actual - el usuario deberá hacer login nuevamente
|
||||
// El backend ya invalidó todas las sesiones, así que limpiamos localmente también
|
||||
setTimeout(async () => {
|
||||
await authService.logout();
|
||||
closeChangePasswordModal();
|
||||
// Recargar página para forzar nuevo login
|
||||
// El evento auth-required se disparará automáticamente cuando intente cargar datos
|
||||
window.location.reload();
|
||||
}, 2000);
|
||||
} catch (error) {
|
||||
console.error('Error cambiando contraseña:', error);
|
||||
if (error.response?.data?.error) {
|
||||
passwordError.value = error.response.data.error;
|
||||
} else {
|
||||
passwordError.value = 'Error cambiando contraseña. Intenta de nuevo.';
|
||||
}
|
||||
} finally {
|
||||
loadingAction.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDeleteUser() {
|
||||
if (!userToDelete.value) return;
|
||||
|
||||
loadingAction.value = true;
|
||||
try {
|
||||
await api.deleteUser(userToDelete.value);
|
||||
userToDelete.value = null;
|
||||
await loadUsers();
|
||||
} catch (error) {
|
||||
console.error('Error eliminando usuario:', error);
|
||||
alert(error.response?.data?.error || 'Error eliminando usuario. Intenta de nuevo.');
|
||||
} finally {
|
||||
loadingAction.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function confirmDeleteUser(username) {
|
||||
userToDelete.value = username;
|
||||
}
|
||||
|
||||
function closeAddModal() {
|
||||
showAddModal.value = false;
|
||||
addError.value = '';
|
||||
userForm.value = {
|
||||
username: '',
|
||||
password: '',
|
||||
passwordConfirm: '',
|
||||
};
|
||||
}
|
||||
|
||||
function closeChangePasswordModal() {
|
||||
showChangePasswordModal.value = false;
|
||||
passwordError.value = '';
|
||||
passwordSuccess.value = '';
|
||||
passwordForm.value = {
|
||||
currentPassword: '',
|
||||
newPassword: '',
|
||||
newPasswordConfirm: '',
|
||||
};
|
||||
}
|
||||
|
||||
function closeTelegramModal() {
|
||||
showTelegramModal.value = false;
|
||||
telegramError.value = '';
|
||||
telegramSuccess.value = '';
|
||||
telegramForm.value = {
|
||||
token: '',
|
||||
channel: '',
|
||||
enable_polling: false
|
||||
};
|
||||
}
|
||||
|
||||
async function loadTelegramConfig() {
|
||||
try {
|
||||
const config = await api.getTelegramConfig();
|
||||
if (config) {
|
||||
telegramForm.value = {
|
||||
token: config.token || '',
|
||||
channel: config.channel || '',
|
||||
enable_polling: config.enable_polling || false
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error cargando configuración de Telegram:', error);
|
||||
telegramError.value = 'Error cargando la configuración de Telegram';
|
||||
}
|
||||
}
|
||||
|
||||
async function saveTelegramConfig() {
|
||||
telegramError.value = '';
|
||||
telegramSuccess.value = '';
|
||||
|
||||
if (!telegramForm.value.token || !telegramForm.value.channel) {
|
||||
telegramError.value = 'Token y canal son requeridos';
|
||||
return;
|
||||
}
|
||||
|
||||
loadingAction.value = true;
|
||||
try {
|
||||
await api.setTelegramConfig(telegramForm.value);
|
||||
telegramSuccess.value = 'Configuración de Telegram guardada correctamente';
|
||||
setTimeout(() => {
|
||||
closeTelegramModal();
|
||||
}, 1500);
|
||||
} catch (error) {
|
||||
console.error('Error guardando configuración de Telegram:', error);
|
||||
if (error.response?.data?.error) {
|
||||
telegramError.value = error.response.data.error;
|
||||
} else {
|
||||
telegramError.value = 'Error al guardar la configuración de Telegram';
|
||||
}
|
||||
} finally {
|
||||
loadingAction.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleAuthLogout() {
|
||||
// Cuando el usuario se desconecta globalmente, limpiar datos
|
||||
users.value = [];
|
||||
showAddModal.value = false;
|
||||
showChangePasswordModal.value = false;
|
||||
userToDelete.value = null;
|
||||
addError.value = '';
|
||||
passwordError.value = '';
|
||||
passwordSuccess.value = '';
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadUsers();
|
||||
window.addEventListener('auth-logout', handleAuthLogout);
|
||||
// Escuchar evento de login exitoso para recargar usuarios
|
||||
window.addEventListener('auth-login', () => {
|
||||
loadUsers();
|
||||
});
|
||||
});
|
||||
|
||||
// Cargar configuración de Telegram cuando se abre el modal
|
||||
watch(showTelegramModal, (newVal) => {
|
||||
if (newVal) {
|
||||
loadTelegramConfig();
|
||||
}
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('auth-logout', handleAuthLogout);
|
||||
window.removeEventListener('auth-login', loadUsers);
|
||||
});
|
||||
</script>
|
||||
|
||||
23
web/landing/.gitignore
vendored
Normal file
@@ -0,0 +1,23 @@
|
||||
# build output
|
||||
dist/
|
||||
.output/
|
||||
|
||||
# generated types
|
||||
.astro/
|
||||
|
||||
# dependencies
|
||||
node_modules/
|
||||
|
||||
# logs
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
|
||||
# environment variables
|
||||
.env
|
||||
.env.production
|
||||
|
||||
# macOS-specific files
|
||||
.DS_Store
|
||||
|
||||
@@ -11,18 +11,19 @@ RUN npm ci
|
||||
# Copiar código fuente
|
||||
COPY . .
|
||||
|
||||
# Construir aplicación
|
||||
# Construir aplicación Astro
|
||||
RUN npm run build
|
||||
|
||||
# Stage de producción - servir con nginx
|
||||
FROM nginx:alpine
|
||||
|
||||
# Copiar archivos construidos
|
||||
# Copiar archivos construidos a la raíz
|
||||
COPY --from=builder /app/dist /usr/share/nginx/html
|
||||
|
||||
# Copiar configuración de nginx
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
|
||||
# Exponer puerto
|
||||
EXPOSE 80
|
||||
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
77
web/landing/README.md
Normal file
@@ -0,0 +1,77 @@
|
||||
# Wallabicher Landing Page
|
||||
|
||||
Landing page moderna y profesional para Wallabicher construida con Astro. Diseño minimalista con modo dark automático basado en las preferencias del sistema.
|
||||
|
||||
## ✨ Características
|
||||
|
||||
- 🎨 **Diseño minimalista y profesional** - Interfaz limpia y moderna
|
||||
- 🌙 **Modo dark automático** - Se adapta automáticamente a las preferencias del sistema
|
||||
- 🎭 **Animaciones suaves** - Transiciones y efectos visuales elegantes
|
||||
- 📱 **Totalmente responsive** - Optimizado para todos los dispositivos
|
||||
- 🎯 **Colores del logo** - Paleta de colores teal/cyan basada en el logo
|
||||
- ⚡ **Rendimiento óptimo** - Construido con Astro para máxima velocidad
|
||||
|
||||
## 🚀 Inicio Rápido
|
||||
|
||||
```bash
|
||||
# Instalar dependencias
|
||||
npm install
|
||||
|
||||
# Iniciar servidor de desarrollo
|
||||
npm run dev
|
||||
|
||||
# Construir para producción
|
||||
npm run build
|
||||
|
||||
# Previsualizar build de producción
|
||||
npm run preview
|
||||
```
|
||||
|
||||
## 📁 Estructura
|
||||
|
||||
```
|
||||
landing/
|
||||
├── public/
|
||||
│ ├── favicon.svg
|
||||
│ └── logo.jpg # Logo de Wallabicher
|
||||
├── src/
|
||||
│ ├── components/
|
||||
│ │ ├── CTA.astro # Sección de llamada a la acción
|
||||
│ │ ├── Features.astro # Características principales
|
||||
│ │ ├── Hero.astro # Sección hero con logo
|
||||
│ │ ├── HowItWorks.astro # Cómo funciona
|
||||
│ │ └── Platforms.astro # Plataformas soportadas
|
||||
│ ├── layouts/
|
||||
│ │ └── Layout.astro # Layout base con modo dark
|
||||
│ └── pages/
|
||||
│ └── index.astro # Página principal
|
||||
├── astro.config.mjs
|
||||
├── package.json
|
||||
└── tailwind.config.mjs # Configuración con colores teal/cyan
|
||||
```
|
||||
|
||||
## 🎨 Tecnologías
|
||||
|
||||
- **Astro** - Framework web moderno y rápido
|
||||
- **Tailwind CSS** - Framework CSS utility-first
|
||||
- **TypeScript** - Tipado estático
|
||||
- **Inter Font** - Tipografía profesional
|
||||
|
||||
## 🎨 Paleta de Colores
|
||||
|
||||
La landing page usa una paleta de colores basada en el logo:
|
||||
- **Primary (Teal/Cyan)**: `#06b6d4` - Color principal
|
||||
- **Teal**: Variaciones de teal para acentos
|
||||
- **Gray**: Escala de grises para texto y fondos
|
||||
- **Modo Dark**: Adaptación automática con colores oscuros
|
||||
|
||||
## 📝 Personalización
|
||||
|
||||
- **Colores**: Modifica `tailwind.config.mjs` para cambiar la paleta
|
||||
- **Componentes**: Edita los archivos en `src/components/`
|
||||
- **Contenido**: Actualiza el texto en cada componente según necesites
|
||||
|
||||
## 🌙 Modo Dark
|
||||
|
||||
El modo dark se detecta automáticamente usando `prefers-color-scheme`. No requiere JavaScript adicional y funciona de forma nativa.
|
||||
|
||||
13
web/landing/astro.config.mjs
Normal file
@@ -0,0 +1,13 @@
|
||||
import { defineConfig } from 'astro/config';
|
||||
import tailwind from '@astrojs/tailwind';
|
||||
|
||||
// https://astro.build/config
|
||||
export default defineConfig({
|
||||
integrations: [tailwind()],
|
||||
output: 'static',
|
||||
server: {
|
||||
port: 3002,
|
||||
host: true,
|
||||
},
|
||||
});
|
||||
|
||||
25
web/landing/nginx.conf
Normal file
@@ -0,0 +1,25 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name localhost;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# Gzip compression
|
||||
gzip on;
|
||||
gzip_vary on;
|
||||
gzip_min_length 1024;
|
||||
gzip_types text/plain text/css text/xml text/javascript application/x-javascript application/xml+rss application/json application/javascript image/svg+xml;
|
||||
|
||||
# Cache static assets
|
||||
location ~* \.(jpg|jpeg|png|gif|ico|svg|css|js|woff|woff2|ttf|eot)$ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
try_files $uri =404;
|
||||
}
|
||||
|
||||
# Serve static files
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
}
|
||||
|
||||
6119
web/landing/package-lock.json
generated
Normal file
18
web/landing/package.json
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"name": "wallabicher-landing",
|
||||
"type": "module",
|
||||
"version": "1.0.0",
|
||||
"scripts": {
|
||||
"dev": "astro dev",
|
||||
"start": "astro dev",
|
||||
"build": "astro build",
|
||||
"preview": "astro preview",
|
||||
"astro": "astro"
|
||||
},
|
||||
"dependencies": {
|
||||
"@astrojs/tailwind": "^5.1.0",
|
||||
"astro": "^4.15.0",
|
||||
"tailwindcss": "^3.4.1"
|
||||
}
|
||||
}
|
||||
|
||||
5
web/landing/public/favicon.svg
Normal file
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
|
||||
<circle cx="50" cy="50" r="45" fill="#0ea5e9"/>
|
||||
<text x="50" y="70" font-size="60" text-anchor="middle" fill="white">🛎️</text>
|
||||
</svg>
|
||||
|
||||
|
After Width: | Height: | Size: 207 B |
BIN
web/landing/public/logo.jpg
Normal file
|
After Width: | Height: | Size: 39 KiB |
91
web/landing/src/components/CTA.astro
Normal file
@@ -0,0 +1,91 @@
|
||||
---
|
||||
---
|
||||
|
||||
<section id="instalacion" class="py-24 sm:py-32 bg-gradient-to-br from-primary-600 via-teal-600 to-cyan-600 dark:from-primary-800 dark:via-teal-800 dark:to-cyan-800 text-white relative overflow-hidden">
|
||||
<!-- Background decoration -->
|
||||
<div class="absolute inset-0">
|
||||
<div class="absolute top-0 left-0 w-96 h-96 bg-white/10 rounded-full blur-3xl"></div>
|
||||
<div class="absolute bottom-0 right-0 w-96 h-96 bg-white/10 rounded-full blur-3xl"></div>
|
||||
<div class="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[600px] h-[600px] bg-white/5 rounded-full blur-3xl"></div>
|
||||
</div>
|
||||
|
||||
<div class="relative z-10 max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 text-center animate-fade-in">
|
||||
<h2 class="text-4xl sm:text-5xl lg:text-6xl font-bold mb-6">
|
||||
¿Listo para empezar?
|
||||
</h2>
|
||||
<p class="text-xl text-primary-100 dark:text-primary-200 mb-12 max-w-2xl mx-auto">
|
||||
Instala Wallabicher en minutos y comienza a recibir notificaciones de los artículos que te interesan.
|
||||
</p>
|
||||
|
||||
<!-- Installation code block -->
|
||||
<div class="mb-12 animate-slide-up" style="animation-delay: 0.2s;">
|
||||
<div class="bg-gray-900/90 dark:bg-gray-950/90 backdrop-blur-sm rounded-2xl p-6 sm:p-8 border border-white/10 shadow-2xl">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div class="flex items-center space-x-2">
|
||||
<div class="w-3 h-3 rounded-full bg-red-500"></div>
|
||||
<div class="w-3 h-3 rounded-full bg-yellow-500"></div>
|
||||
<div class="w-3 h-3 rounded-full bg-green-500"></div>
|
||||
</div>
|
||||
<span class="text-sm text-gray-400 font-mono">Terminal</span>
|
||||
</div>
|
||||
<pre class="text-left overflow-x-auto"><code class="text-sm sm:text-base text-gray-100 font-mono"># 1. Instala las dependencias
|
||||
pip3 install -r requirements.txt
|
||||
|
||||
# 2. Configura el proyecto
|
||||
python setup_config.py
|
||||
|
||||
# 3. Edita config.yaml con tus credenciales de Telegram
|
||||
|
||||
# 4. Personaliza workers.json con tus búsquedas
|
||||
|
||||
# 5. Ejecuta Wallabicher
|
||||
python3 wallabicher.py</code></pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- CTA Buttons -->
|
||||
<div class="flex flex-col sm:flex-row gap-4 justify-center items-center animate-slide-up" style="animation-delay: 0.3s;">
|
||||
<a
|
||||
href="https://github.com"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="group inline-flex items-center justify-center px-8 py-4 text-lg font-semibold text-primary-900 bg-white rounded-xl hover:bg-primary-50 transition-all duration-300 shadow-xl hover:shadow-2xl transform hover:-translate-y-1"
|
||||
>
|
||||
<span>Ver Documentación Completa</span>
|
||||
<svg class="w-5 h-5 ml-2 transform group-hover:translate-x-1 transition-transform" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"></path>
|
||||
</svg>
|
||||
</a>
|
||||
<a
|
||||
href="https://github.com"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="inline-flex items-center justify-center px-8 py-4 text-lg font-semibold text-white bg-white/10 backdrop-blur-sm rounded-xl hover:bg-white/20 transition-all duration-300 border-2 border-white/30 hover:border-white/50 shadow-xl hover:shadow-2xl transform hover:-translate-y-1"
|
||||
>
|
||||
<svg class="w-5 h-5 mr-2" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/>
|
||||
</svg>
|
||||
Descargar en GitHub
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<footer class="bg-gray-900 dark:bg-gray-950 text-gray-300 dark:text-gray-600 py-12 border-t border-gray-800 dark:border-gray-800">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div class="text-center">
|
||||
<div class="flex items-center justify-center mb-6">
|
||||
<div class="w-12 h-12 bg-white/10 rounded-xl p-2 mr-3">
|
||||
<img src="/logo.jpg" alt="Wallabicher" class="w-full h-full object-contain" />
|
||||
</div>
|
||||
<h3 class="text-xl font-bold text-white dark:text-gray-300">Wallabicher</h3>
|
||||
</div>
|
||||
<p class="text-base mb-4 text-gray-400 dark:text-gray-500">
|
||||
Hecho con <span class="text-red-500">❤️</span> para ayudarte a encontrar las mejores ofertas
|
||||
</p>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-600">
|
||||
© 2024 Wallabicher. Todos los derechos reservados.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
98
web/landing/src/components/Features.astro
Normal file
@@ -0,0 +1,98 @@
|
||||
---
|
||||
const features = [
|
||||
{
|
||||
icon: '🔍',
|
||||
title: 'Búsquedas Automatizadas',
|
||||
description: 'Configura tus criterios de búsqueda una vez y deja que Wallabicher monitoree las plataformas 24/7 sin interrupciones.',
|
||||
color: 'from-primary-500 to-primary-600',
|
||||
delay: '0.1s',
|
||||
},
|
||||
{
|
||||
icon: '⚡',
|
||||
title: 'Notificaciones Instantáneas',
|
||||
description: 'Recibe alertas en tiempo real en tu canal o chat de Telegram cuando aparezcan artículos que coincidan con tus filtros.',
|
||||
color: 'from-teal-500 to-teal-600',
|
||||
delay: '0.2s',
|
||||
},
|
||||
{
|
||||
icon: '🎯',
|
||||
title: 'Filtros Avanzados',
|
||||
description: 'Filtra por precio, ubicación, condición, palabras clave y mucho más. Control total sobre lo que quieres ver.',
|
||||
color: 'from-cyan-500 to-cyan-600',
|
||||
delay: '0.3s',
|
||||
},
|
||||
{
|
||||
icon: '📱',
|
||||
title: 'Integración con Telegram',
|
||||
description: 'Recibe notificaciones con imágenes, descripciones y enlaces directos. Incluso organiza por temas en grupos.',
|
||||
color: 'from-primary-500 to-teal-500',
|
||||
delay: '0.4s',
|
||||
},
|
||||
{
|
||||
icon: '⭐',
|
||||
title: 'Sistema de Favoritos',
|
||||
description: 'Guarda tus artículos favoritos con un solo clic y accede a ellos cuando quieras con el comando /favs.',
|
||||
color: 'from-teal-500 to-cyan-500',
|
||||
delay: '0.5s',
|
||||
},
|
||||
{
|
||||
icon: '🌐',
|
||||
title: 'Multi-Plataforma',
|
||||
description: 'Soporta múltiples marketplaces: Wallapop, Vinted y más. Arquitectura extensible para añadir nuevas plataformas fácilmente.',
|
||||
color: 'from-cyan-500 to-primary-500',
|
||||
delay: '0.6s',
|
||||
},
|
||||
];
|
||||
---
|
||||
|
||||
<section id="caracteristicas" class="py-24 sm:py-32 bg-white dark:bg-gray-950 relative overflow-hidden">
|
||||
<!-- Background decoration -->
|
||||
<div class="absolute top-0 left-0 w-full h-full">
|
||||
<div class="absolute top-0 right-0 w-96 h-96 bg-primary-100/30 dark:bg-primary-900/20 rounded-full blur-3xl"></div>
|
||||
<div class="absolute bottom-0 left-0 w-96 h-96 bg-teal-100/30 dark:bg-teal-900/20 rounded-full blur-3xl"></div>
|
||||
</div>
|
||||
|
||||
<div class="relative z-10 max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div class="text-center mb-16 animate-fade-in">
|
||||
<h2 class="text-4xl sm:text-5xl lg:text-6xl font-bold text-gray-900 dark:text-gray-50 mb-4">
|
||||
Características
|
||||
<span class="block text-transparent bg-clip-text bg-gradient-to-r from-primary-600 via-teal-600 to-cyan-600 dark:from-primary-400 dark:via-teal-400 dark:to-cyan-400">
|
||||
Principales
|
||||
</span>
|
||||
</h2>
|
||||
<p class="text-xl text-gray-600 dark:text-gray-400 max-w-2xl mx-auto">
|
||||
Todo lo que necesitas para no perderte ningún artículo interesante
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
|
||||
{features.map((feature, index) => (
|
||||
<div
|
||||
class="group relative p-8 rounded-2xl bg-gradient-to-br from-white to-gray-50 dark:from-gray-900 dark:to-gray-800 border border-gray-200 dark:border-gray-800 hover:border-primary-300 dark:hover:border-primary-700 transition-all duration-300 transform hover:-translate-y-2 hover:shadow-2xl animate-slide-up"
|
||||
style={`animation-delay: ${feature.delay}`}
|
||||
>
|
||||
<!-- Gradient overlay on hover -->
|
||||
<div class={`absolute inset-0 bg-gradient-to-br ${feature.color} opacity-0 group-hover:opacity-5 rounded-2xl transition-opacity duration-300`}></div>
|
||||
|
||||
<div class="relative z-10">
|
||||
<!-- Icon -->
|
||||
<div class="inline-flex items-center justify-center w-16 h-16 rounded-xl bg-gradient-to-br from-primary-500/10 to-teal-500/10 dark:from-primary-500/20 dark:to-teal-500/20 mb-6 group-hover:scale-110 transition-transform duration-300">
|
||||
<span class="text-3xl">{feature.icon}</span>
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<h3 class="text-xl font-bold text-gray-900 dark:text-gray-50 mb-3 group-hover:text-primary-600 dark:group-hover:text-primary-400 transition-colors">
|
||||
{feature.title}
|
||||
</h3>
|
||||
<p class="text-gray-600 dark:text-gray-400 leading-relaxed">
|
||||
{feature.description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Decorative corner -->
|
||||
<div class={`absolute top-0 right-0 w-20 h-20 bg-gradient-to-br ${feature.color} opacity-0 group-hover:opacity-10 rounded-bl-full transition-opacity duration-300`}></div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
84
web/landing/src/components/Hero.astro
Normal file
@@ -0,0 +1,84 @@
|
||||
---
|
||||
---
|
||||
|
||||
<section class="relative min-h-screen flex items-center justify-center overflow-hidden bg-gradient-to-br from-primary-50 via-teal-50 to-cyan-50 dark:from-gray-950 dark:via-gray-900 dark:to-primary-950">
|
||||
<!-- Background decorative elements -->
|
||||
<div class="absolute inset-0 overflow-hidden">
|
||||
<div class="absolute top-0 -left-4 w-96 h-96 bg-primary-300/30 dark:bg-primary-800/20 rounded-full blur-3xl animate-float"></div>
|
||||
<div class="absolute bottom-0 -right-4 w-96 h-96 bg-teal-300/30 dark:bg-teal-800/20 rounded-full blur-3xl animate-float" style="animation-delay: 2s;"></div>
|
||||
<div class="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[600px] h-[600px] bg-primary-200/20 dark:bg-primary-900/20 rounded-full blur-3xl animate-float" style="animation-delay: 4s;"></div>
|
||||
</div>
|
||||
|
||||
<!-- Grid pattern overlay -->
|
||||
<div class="absolute inset-0 bg-grid-pattern opacity-[0.03] dark:opacity-[0.05]"></div>
|
||||
|
||||
<div class="relative z-10 max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-24 sm:py-32">
|
||||
<div class="text-center animate-fade-in">
|
||||
<!-- Logo -->
|
||||
<div class="flex justify-center mb-8 animate-slide-up">
|
||||
<div class="relative">
|
||||
<div class="absolute inset-0 bg-primary-500/20 dark:bg-primary-600/30 rounded-2xl blur-xl"></div>
|
||||
<div class="relative w-24 h-24 sm:w-32 sm:h-32 bg-white/80 dark:bg-gray-900/80 backdrop-blur-sm rounded-2xl p-3 shadow-2xl ring-2 ring-primary-200/50 dark:ring-primary-800/50">
|
||||
<img
|
||||
src="/logo.jpg"
|
||||
alt="Wallabicher Logo"
|
||||
class="w-full h-full object-contain rounded-lg"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Title -->
|
||||
<h1 class="text-5xl sm:text-6xl lg:text-7xl xl:text-8xl font-bold tracking-tight mb-6 animate-slide-up" style="animation-delay: 0.1s;">
|
||||
<span class="bg-gradient-to-r from-primary-600 via-teal-600 to-cyan-600 dark:from-primary-400 dark:via-teal-400 dark:to-cyan-400 bg-clip-text text-transparent">
|
||||
Wallabicher
|
||||
</span>
|
||||
<span class="block text-4xl sm:text-5xl lg:text-6xl mt-2">🛎️</span>
|
||||
</h1>
|
||||
|
||||
<!-- Subtitle -->
|
||||
<p class="text-xl sm:text-2xl lg:text-3xl text-gray-700 dark:text-gray-300 max-w-3xl mx-auto mb-4 font-medium animate-slide-up" style="animation-delay: 0.2s;">
|
||||
Automatiza tus búsquedas en marketplaces
|
||||
</p>
|
||||
<p class="text-lg sm:text-xl text-gray-600 dark:text-gray-400 max-w-2xl mx-auto mb-12 animate-slide-up" style="animation-delay: 0.3s;">
|
||||
Recibe notificaciones instantáneas en Telegram cuando aparezcan artículos que te interesan
|
||||
</p>
|
||||
|
||||
<!-- CTA Buttons -->
|
||||
<div class="flex flex-col sm:flex-row gap-4 justify-center items-center animate-slide-up" style="animation-delay: 0.4s;">
|
||||
<a
|
||||
href="#precios"
|
||||
class="group relative inline-flex items-center justify-center px-8 py-4 text-lg font-semibold text-white bg-gradient-to-r from-primary-600 to-teal-600 hover:from-primary-700 hover:to-teal-700 rounded-xl shadow-lg hover:shadow-xl transition-all duration-300 transform hover:-translate-y-1"
|
||||
>
|
||||
<span class="relative z-10">Ver Planes</span>
|
||||
<div class="absolute inset-0 bg-gradient-to-r from-primary-700 to-teal-700 rounded-xl opacity-0 group-hover:opacity-100 transition-opacity duration-300"></div>
|
||||
</a>
|
||||
<a
|
||||
href="/dashboard/register?plan=free"
|
||||
class="inline-flex items-center justify-center px-8 py-4 text-lg font-semibold text-gray-900 dark:text-gray-100 bg-white/80 dark:bg-gray-800/80 backdrop-blur-sm rounded-xl hover:bg-white dark:hover:bg-gray-800 transition-all duration-300 border-2 border-gray-200 dark:border-gray-700 shadow-lg hover:shadow-xl transform hover:-translate-y-1"
|
||||
>
|
||||
Empezar gratis
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Scroll indicator -->
|
||||
<div class="absolute bottom-8 left-1/2 -translate-x-1/2 animate-bounce">
|
||||
<svg class="w-6 h-6 text-gray-400 dark:text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 14l-7 7m0 0l-7-7m7 7V3"></path>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Gradient fade at bottom -->
|
||||
<div class="absolute bottom-0 left-0 right-0 h-32 bg-gradient-to-t from-white dark:from-gray-950 to-transparent"></div>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.bg-grid-pattern {
|
||||
background-image:
|
||||
linear-gradient(to right, currentColor 1px, transparent 1px),
|
||||
linear-gradient(to bottom, currentColor 1px, transparent 1px);
|
||||
background-size: 40px 40px;
|
||||
}
|
||||
</style>
|
||||
103
web/landing/src/components/HowItWorks.astro
Normal file
@@ -0,0 +1,103 @@
|
||||
---
|
||||
const steps = [
|
||||
{
|
||||
number: '1',
|
||||
title: 'Configura tus búsquedas',
|
||||
description: 'Define tus criterios: término de búsqueda, rango de precios, ubicación, filtros avanzados y más.',
|
||||
icon: '⚙️',
|
||||
},
|
||||
{
|
||||
number: '2',
|
||||
title: 'Conecta Telegram',
|
||||
description: 'Configura tu token de Telegram y el canal o chat donde quieres recibir las notificaciones.',
|
||||
icon: '📱',
|
||||
},
|
||||
{
|
||||
number: '3',
|
||||
title: 'Ejecuta Wallabicher',
|
||||
description: 'Inicia el monitor y deja que trabaje en segundo plano. Revisará las plataformas automáticamente.',
|
||||
icon: '🚀',
|
||||
},
|
||||
{
|
||||
number: '4',
|
||||
title: 'Recibe notificaciones',
|
||||
description: 'Cuando aparezca un artículo que coincida con tus filtros, recibirás una notificación instantánea con toda la información.',
|
||||
icon: '🔔',
|
||||
},
|
||||
];
|
||||
---
|
||||
|
||||
<section id="como-funciona" class="py-24 sm:py-32 bg-white dark:bg-gray-950 relative overflow-hidden">
|
||||
<!-- Background decoration -->
|
||||
<div class="absolute inset-0">
|
||||
<div class="absolute top-0 left-1/4 w-96 h-96 bg-cyan-100/20 dark:bg-cyan-900/20 rounded-full blur-3xl"></div>
|
||||
<div class="absolute bottom-0 right-1/4 w-96 h-96 bg-primary-100/20 dark:bg-primary-900/20 rounded-full blur-3xl"></div>
|
||||
</div>
|
||||
|
||||
<div class="relative z-10 max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div class="text-center mb-16 animate-fade-in">
|
||||
<h2 class="text-4xl sm:text-5xl lg:text-6xl font-bold text-gray-900 dark:text-gray-50 mb-4">
|
||||
¿Cómo
|
||||
<span class="block text-transparent bg-clip-text bg-gradient-to-r from-primary-600 via-teal-600 to-cyan-600 dark:from-primary-400 dark:via-teal-400 dark:to-cyan-400">
|
||||
Funciona?
|
||||
</span>
|
||||
</h2>
|
||||
<p class="text-xl text-gray-600 dark:text-gray-400 max-w-2xl mx-auto">
|
||||
Configuración simple en 4 pasos
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="relative">
|
||||
<!-- Connection line (desktop only) -->
|
||||
<div class="hidden lg:block absolute top-16 left-0 right-0 h-0.5 bg-gradient-to-r from-primary-200 via-teal-200 to-cyan-200 dark:from-primary-800 dark:via-teal-800 dark:to-cyan-800"></div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-8">
|
||||
{steps.map((step, index) => (
|
||||
<div class="relative animate-slide-up" style={`animation-delay: ${0.1 + index * 0.1}s`}>
|
||||
<!-- Step card -->
|
||||
<div class="group relative">
|
||||
<!-- Connection dot (desktop only) -->
|
||||
<div class="hidden lg:block absolute top-16 left-1/2 -translate-x-1/2 w-4 h-4 bg-gradient-to-br from-primary-500 to-teal-500 rounded-full z-10 ring-4 ring-white dark:ring-gray-950"></div>
|
||||
|
||||
<div class="relative pt-20 lg:pt-0">
|
||||
<!-- Icon circle -->
|
||||
<div class="flex justify-center mb-6 lg:mb-8">
|
||||
<div class="relative">
|
||||
<div class="absolute inset-0 bg-gradient-to-br from-primary-500 to-teal-500 rounded-full blur-xl opacity-50 group-hover:opacity-75 transition-opacity"></div>
|
||||
<div class="relative w-20 h-20 lg:w-24 lg:h-24 rounded-full bg-gradient-to-br from-primary-600 via-teal-600 to-cyan-600 dark:from-primary-500 dark:via-teal-500 dark:to-cyan-500 flex items-center justify-center shadow-xl group-hover:scale-110 transition-transform duration-300">
|
||||
<span class="text-3xl lg:text-4xl">{step.icon}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Number badge -->
|
||||
<div class="absolute top-0 left-1/2 -translate-x-1/2 lg:top-4 lg:left-4 w-12 h-12 rounded-full bg-gradient-to-br from-primary-600 to-teal-600 dark:from-primary-500 dark:to-teal-500 text-white text-xl font-bold flex items-center justify-center shadow-lg ring-4 ring-white dark:ring-gray-950 z-20">
|
||||
{step.number}
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="text-center">
|
||||
<h3 class="text-xl font-bold text-gray-900 dark:text-gray-50 mb-3 group-hover:text-primary-600 dark:group-hover:text-primary-400 transition-colors">
|
||||
{step.title}
|
||||
</h3>
|
||||
<p class="text-gray-600 dark:text-gray-400 leading-relaxed">
|
||||
{step.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Arrow (desktop only, except last) -->
|
||||
{index < steps.length - 1 && (
|
||||
<div class="hidden lg:block absolute top-16 left-full w-full">
|
||||
<div class="relative h-0.5 bg-gradient-to-r from-primary-200 to-teal-200 dark:from-primary-800 dark:to-teal-800">
|
||||
<div class="absolute right-0 top-1/2 -translate-y-1/2 w-0 h-0 border-l-8 border-l-teal-200 dark:border-l-teal-800 border-t-4 border-t-transparent border-b-4 border-b-transparent"></div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
103
web/landing/src/components/Platforms.astro
Normal file
@@ -0,0 +1,103 @@
|
||||
---
|
||||
const platforms = [
|
||||
{
|
||||
name: 'Wallapop',
|
||||
status: 'Totalmente funcional',
|
||||
statusColor: 'bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400',
|
||||
icon: '🟢',
|
||||
description: 'Monitoriza búsquedas en Wallapop con todos los filtros disponibles y notificaciones en tiempo real.',
|
||||
gradient: 'from-primary-500 to-primary-600',
|
||||
},
|
||||
{
|
||||
name: 'Vinted',
|
||||
status: 'Implementado',
|
||||
statusColor: 'bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-400',
|
||||
icon: '🟢',
|
||||
description: 'Soporte para múltiples países. Requiere intervalos de búsqueda más largos para evitar bloqueos.',
|
||||
gradient: 'from-teal-500 to-teal-600',
|
||||
},
|
||||
{
|
||||
name: 'Buyee',
|
||||
status: 'Por implementar',
|
||||
statusColor: 'bg-yellow-100 dark:bg-yellow-900/30 text-yellow-700 dark:text-yellow-400',
|
||||
icon: '🟡',
|
||||
description: 'Próximamente disponible. Arquitectura extensible lista para nuevas plataformas.',
|
||||
gradient: 'from-gray-400 to-gray-500',
|
||||
},
|
||||
];
|
||||
---
|
||||
|
||||
<section class="py-24 sm:py-32 bg-gradient-to-b from-white via-gray-50 to-white dark:from-gray-950 dark:via-gray-900 dark:to-gray-950 relative overflow-hidden">
|
||||
<!-- Background decoration -->
|
||||
<div class="absolute inset-0">
|
||||
<div class="absolute top-1/4 left-0 w-72 h-72 bg-primary-200/20 dark:bg-primary-900/20 rounded-full blur-3xl"></div>
|
||||
<div class="absolute bottom-1/4 right-0 w-72 h-72 bg-teal-200/20 dark:bg-teal-900/20 rounded-full blur-3xl"></div>
|
||||
</div>
|
||||
|
||||
<div class="relative z-10 max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div class="text-center mb-16 animate-fade-in">
|
||||
<h2 class="text-4xl sm:text-5xl lg:text-6xl font-bold text-gray-900 dark:text-gray-50 mb-4">
|
||||
Plataformas
|
||||
<span class="block text-transparent bg-clip-text bg-gradient-to-r from-primary-600 via-teal-600 to-cyan-600 dark:from-primary-400 dark:via-teal-400 dark:to-cyan-400">
|
||||
Soportadas
|
||||
</span>
|
||||
</h2>
|
||||
<p class="text-xl text-gray-600 dark:text-gray-400 max-w-2xl mx-auto">
|
||||
Arquitectura extensible que permite añadir nuevas plataformas fácilmente
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-8 mb-12">
|
||||
{platforms.map((platform, index) => (
|
||||
<div
|
||||
class="group relative p-8 rounded-2xl bg-white dark:bg-gray-900 border-2 border-gray-200 dark:border-gray-800 hover:border-primary-300 dark:hover:border-primary-700 transition-all duration-300 transform hover:-translate-y-2 hover:shadow-2xl animate-slide-up"
|
||||
style={`animation-delay: ${0.1 + index * 0.1}s`}
|
||||
>
|
||||
<!-- Gradient background on hover -->
|
||||
<div class={`absolute inset-0 bg-gradient-to-br ${platform.gradient} opacity-0 group-hover:opacity-5 rounded-2xl transition-opacity duration-300`}></div>
|
||||
|
||||
<div class="relative z-10">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<h3 class="text-2xl font-bold text-gray-900 dark:text-gray-50">{platform.name}</h3>
|
||||
<span class="text-3xl transform group-hover:scale-110 transition-transform duration-300">{platform.icon}</span>
|
||||
</div>
|
||||
|
||||
<!-- Status badge -->
|
||||
<span class={`inline-block px-3 py-1 rounded-full text-xs font-semibold mb-4 ${platform.statusColor}`}>
|
||||
{platform.status}
|
||||
</span>
|
||||
|
||||
<!-- Description -->
|
||||
<p class="text-gray-600 dark:text-gray-400 leading-relaxed">
|
||||
{platform.description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Decorative element -->
|
||||
<div class={`absolute bottom-0 right-0 w-24 h-24 bg-gradient-to-br ${platform.gradient} opacity-0 group-hover:opacity-10 rounded-tl-full transition-opacity duration-300`}></div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<!-- CTA -->
|
||||
<div class="text-center animate-fade-in" style="animation-delay: 0.4s;">
|
||||
<div class="inline-block p-8 rounded-2xl bg-gradient-to-br from-primary-50 to-teal-50 dark:from-gray-900 dark:to-gray-800 border border-primary-200 dark:border-primary-800">
|
||||
<p class="text-lg text-gray-700 dark:text-gray-300 mb-4 font-medium">
|
||||
¿Quieres añadir una nueva plataforma?
|
||||
</p>
|
||||
<a
|
||||
href="https://github.com"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="inline-flex items-center text-primary-600 dark:text-primary-400 hover:text-primary-700 dark:hover:text-primary-300 font-semibold group"
|
||||
>
|
||||
Consulta la documentación
|
||||
<svg class="w-5 h-5 ml-2 transform group-hover:translate-x-1 transition-transform" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"></path>
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
231
web/landing/src/components/Pricing.astro
Normal file
@@ -0,0 +1,231 @@
|
||||
---
|
||||
const plans = [
|
||||
{
|
||||
id: 'free',
|
||||
name: 'Gratis',
|
||||
description: 'Perfecto para empezar',
|
||||
price: { monthly: 0, yearly: 0 },
|
||||
popular: false,
|
||||
features: [
|
||||
'Hasta 2 búsquedas simultáneas',
|
||||
'Solo Wallapop',
|
||||
'50 notificaciones por día',
|
||||
'Soporte por email',
|
||||
],
|
||||
limits: {
|
||||
workers: 2,
|
||||
notifications: 50,
|
||||
platforms: ['Wallapop'],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'basic',
|
||||
name: 'Básico',
|
||||
description: 'Para usuarios ocasionales',
|
||||
price: { monthly: 9.99, yearly: 99.99 },
|
||||
popular: true,
|
||||
features: [
|
||||
'Hasta 5 búsquedas simultáneas',
|
||||
'Wallapop y Vinted',
|
||||
'200 notificaciones por día',
|
||||
'Soporte prioritario',
|
||||
'Sin límite de favoritos',
|
||||
],
|
||||
limits: {
|
||||
workers: 5,
|
||||
notifications: 200,
|
||||
platforms: ['Wallapop', 'Vinted'],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'pro',
|
||||
name: 'Pro',
|
||||
description: 'Para usuarios avanzados',
|
||||
price: { monthly: 19.99, yearly: 199.99 },
|
||||
popular: false,
|
||||
features: [
|
||||
'Hasta 15 búsquedas simultáneas',
|
||||
'Todas las plataformas',
|
||||
'1000 notificaciones por día',
|
||||
'Soporte prioritario 24/7',
|
||||
'API access',
|
||||
'Webhooks personalizados',
|
||||
],
|
||||
limits: {
|
||||
workers: 15,
|
||||
notifications: 1000,
|
||||
platforms: ['Wallapop', 'Vinted', 'Buyee'],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'enterprise',
|
||||
name: 'Enterprise',
|
||||
description: 'Para equipos y uso intensivo',
|
||||
price: { monthly: 49.99, yearly: 499.99 },
|
||||
popular: false,
|
||||
features: [
|
||||
'Búsquedas ilimitadas',
|
||||
'Notificaciones ilimitadas',
|
||||
'Todas las plataformas',
|
||||
'Soporte dedicado',
|
||||
'API completa',
|
||||
'Webhooks personalizados',
|
||||
'Gestión de múltiples usuarios',
|
||||
'Estadísticas avanzadas',
|
||||
],
|
||||
limits: {
|
||||
workers: 'Ilimitado',
|
||||
notifications: 'Ilimitado',
|
||||
platforms: ['Todas'],
|
||||
},
|
||||
},
|
||||
];
|
||||
---
|
||||
|
||||
<section id="precios" class="py-24 sm:py-32 bg-gradient-to-b from-white via-gray-50 to-white dark:from-gray-950 dark:via-gray-900 dark:to-gray-950 relative overflow-hidden">
|
||||
<!-- Background decoration -->
|
||||
<div class="absolute inset-0">
|
||||
<div class="absolute top-0 left-1/4 w-96 h-96 bg-primary-200/20 dark:bg-primary-900/20 rounded-full blur-3xl"></div>
|
||||
<div class="absolute bottom-0 right-1/4 w-96 h-96 bg-teal-200/20 dark:bg-teal-900/20 rounded-full blur-3xl"></div>
|
||||
</div>
|
||||
|
||||
<div class="relative z-10 max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div class="text-center mb-16 animate-fade-in">
|
||||
<h2 class="text-4xl sm:text-5xl lg:text-6xl font-bold text-gray-900 dark:text-gray-50 mb-4">
|
||||
Planes y
|
||||
<span class="block text-transparent bg-clip-text bg-gradient-to-r from-primary-600 via-teal-600 to-cyan-600 dark:from-primary-400 dark:via-teal-400 dark:to-cyan-400">
|
||||
Precios
|
||||
</span>
|
||||
</h2>
|
||||
<p class="text-xl text-gray-600 dark:text-gray-400 max-w-2xl mx-auto">
|
||||
Elige el plan que mejor se adapte a tus necesidades
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Toggle mensual/anual -->
|
||||
<div class="flex justify-center mb-12">
|
||||
<div class="inline-flex items-center bg-gray-100 dark:bg-gray-800 rounded-lg p-1">
|
||||
<button id="billing-monthly" class="px-4 py-2 rounded-md text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 shadow-sm transition-all">
|
||||
Mensual
|
||||
</button>
|
||||
<button id="billing-yearly" class="px-4 py-2 rounded-md text-sm font-medium text-gray-700 dark:text-gray-300 transition-all">
|
||||
Anual
|
||||
<span class="ml-1 text-xs text-primary-600 dark:text-primary-400">-17%</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-8">
|
||||
{plans.map((plan, index) => (
|
||||
<div
|
||||
class={`relative p-8 rounded-2xl bg-white dark:bg-gray-900 border-2 transition-all duration-300 transform hover:-translate-y-2 hover:shadow-2xl animate-slide-up flex flex-col ${
|
||||
plan.popular
|
||||
? 'border-primary-500 dark:border-primary-400 shadow-xl scale-105'
|
||||
: 'border-gray-200 dark:border-gray-800 hover:border-primary-300 dark:hover:border-primary-700'
|
||||
}`}
|
||||
style={`animation-delay: ${0.1 + index * 0.1}s`}
|
||||
>
|
||||
{plan.popular && (
|
||||
<div class="absolute -top-4 left-1/2 -translate-x-1/2 px-4 py-1 bg-gradient-to-r from-primary-600 to-teal-600 text-white text-sm font-semibold rounded-full">
|
||||
Más Popular
|
||||
</div>
|
||||
)}
|
||||
|
||||
<!-- Header -->
|
||||
<div class="text-center mb-8">
|
||||
<h3 class="text-2xl font-bold text-gray-900 dark:text-gray-50 mb-2">{plan.name}</h3>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400 mb-6">{plan.description}</p>
|
||||
|
||||
<!-- Precio -->
|
||||
<div class="mb-6">
|
||||
<div class="price-monthly">
|
||||
<span class="text-4xl font-bold text-gray-900 dark:text-gray-50">
|
||||
€{plan.price.monthly.toFixed(2)}
|
||||
</span>
|
||||
<span class="text-gray-600 dark:text-gray-400">/mes</span>
|
||||
</div>
|
||||
<div class="price-yearly hidden">
|
||||
<span class="text-4xl font-bold text-gray-900 dark:text-gray-50">
|
||||
€{plan.price.yearly.toFixed(2)}
|
||||
</span>
|
||||
<span class="text-gray-600 dark:text-gray-400">/año</span>
|
||||
<div class="text-sm text-primary-600 dark:text-primary-400 mt-1">
|
||||
€{(plan.price.yearly / 12).toFixed(2)}/mes
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Features -->
|
||||
<ul class="space-y-4 mb-8 flex-grow">
|
||||
{plan.features.map((feature) => (
|
||||
<li class="flex items-start">
|
||||
<svg class="w-5 h-5 text-primary-600 dark:text-primary-400 mr-3 mt-0.5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path>
|
||||
</svg>
|
||||
<span class="text-gray-700 dark:text-gray-300">{feature}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<!-- CTA Button -->
|
||||
<a
|
||||
href={`/dashboard/register?plan=${plan.id}`}
|
||||
class={`block w-full text-center px-6 py-3 rounded-xl font-semibold transition-all duration-300 mt-auto ${
|
||||
plan.popular
|
||||
? 'bg-gradient-to-r from-primary-600 to-teal-600 text-white hover:from-primary-700 hover:to-teal-700 shadow-lg hover:shadow-xl'
|
||||
: plan.id === 'free'
|
||||
? 'bg-gray-100 dark:bg-gray-800 text-gray-900 dark:text-gray-100 hover:bg-gray-200 dark:hover:bg-gray-700'
|
||||
: 'bg-primary-50 dark:bg-primary-900/30 text-primary-700 dark:text-primary-400 hover:bg-primary-100 dark:hover:bg-primary-900/50 border-2 border-primary-200 dark:border-primary-800'
|
||||
}`}
|
||||
>
|
||||
{plan.id === 'free' ? 'Empezar gratis' : 'Elegir plan'}
|
||||
</a>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<!-- FAQ o nota adicional -->
|
||||
<div class="mt-16 text-center">
|
||||
<p class="text-gray-600 dark:text-gray-400 mb-4">
|
||||
¿Necesitas un plan personalizado?
|
||||
</p>
|
||||
<a
|
||||
href="mailto:soporte@wallabicher.com"
|
||||
class="inline-flex items-center text-primary-600 dark:text-primary-400 hover:text-primary-700 dark:hover:text-primary-300 font-semibold"
|
||||
>
|
||||
Contacta con nosotros
|
||||
<svg class="w-5 h-5 ml-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"></path>
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<script>
|
||||
// Toggle entre mensual y anual
|
||||
const monthlyBtn = document.getElementById('billing-monthly');
|
||||
const yearlyBtn = document.getElementById('billing-yearly');
|
||||
const monthlyPrices = document.querySelectorAll('.price-monthly');
|
||||
const yearlyPrices = document.querySelectorAll('.price-yearly');
|
||||
|
||||
monthlyBtn?.addEventListener('click', () => {
|
||||
monthlyBtn.classList.add('bg-white', 'dark:bg-gray-700', 'shadow-sm');
|
||||
monthlyBtn.classList.remove('text-gray-700', 'dark:text-gray-300');
|
||||
yearlyBtn.classList.remove('bg-white', 'dark:bg-gray-700', 'shadow-sm');
|
||||
yearlyBtn.classList.add('text-gray-700', 'dark:text-gray-300');
|
||||
monthlyPrices.forEach(p => p.classList.remove('hidden'));
|
||||
yearlyPrices.forEach(p => p.classList.add('hidden'));
|
||||
});
|
||||
|
||||
yearlyBtn?.addEventListener('click', () => {
|
||||
yearlyBtn.classList.add('bg-white', 'dark:bg-gray-700', 'shadow-sm');
|
||||
yearlyBtn.classList.remove('text-gray-700', 'dark:text-gray-300');
|
||||
monthlyBtn.classList.remove('bg-white', 'dark:bg-gray-700', 'shadow-sm');
|
||||
monthlyBtn.classList.add('text-gray-700', 'dark:text-gray-300');
|
||||
yearlyPrices.forEach(p => p.classList.remove('hidden'));
|
||||
monthlyPrices.forEach(p => p.classList.add('hidden'));
|
||||
});
|
||||
</script>
|
||||
|
||||
1
web/landing/src/env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/// <reference path="../.astro/types.d.ts" />
|
||||
63
web/landing/src/layouts/Layout.astro
Normal file
@@ -0,0 +1,63 @@
|
||||
---
|
||||
interface Props {
|
||||
title: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
const { title, description = "Automatiza tus búsquedas en marketplaces (Wallapop, Vinted, etc.) y recibe notificaciones instantáneas en Telegram cuando aparezcan nuevos artículos." } = Astro.props;
|
||||
---
|
||||
|
||||
<!doctype html>
|
||||
<html lang="es">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="description" content={description} />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="generator" content={Astro.generator} />
|
||||
<meta name="theme-color" content="#06b6d4" />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800;900&display=swap" rel="stylesheet" />
|
||||
<title>{title}</title>
|
||||
</head>
|
||||
<body class="bg-white dark:bg-gray-950 text-gray-900 dark:text-gray-50 antialiased transition-colors duration-300">
|
||||
<slot />
|
||||
</body>
|
||||
</html>
|
||||
|
||||
<style is:global>
|
||||
html {
|
||||
font-family: 'Inter', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
* {
|
||||
@apply border-gray-200 dark:border-gray-800;
|
||||
}
|
||||
|
||||
/* Scrollbar styling */
|
||||
::-webkit-scrollbar {
|
||||
@apply w-2 h-2;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
@apply bg-gray-100 dark:bg-gray-900;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
@apply bg-gray-300 dark:bg-gray-700 rounded-full;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
@apply bg-gray-400 dark:bg-gray-600;
|
||||
}
|
||||
|
||||
/* Smooth transitions */
|
||||
* {
|
||||
transition-property: color, background-color, border-color;
|
||||
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
|
||||
transition-duration: 200ms;
|
||||
}
|
||||
</style>
|
||||
|
||||
15
web/landing/src/pages/index.astro
Normal file
@@ -0,0 +1,15 @@
|
||||
---
|
||||
import Layout from '../layouts/Layout.astro';
|
||||
import Hero from '../components/Hero.astro';
|
||||
import Features from '../components/Features.astro';
|
||||
import Platforms from '../components/Platforms.astro';
|
||||
import Pricing from '../components/Pricing.astro';
|
||||
---
|
||||
|
||||
<Layout title="Wallabicher - Monitoriza Marketplaces Automáticamente">
|
||||
<Hero />
|
||||
<Features />
|
||||
<Platforms />
|
||||
<Pricing />
|
||||
</Layout>
|
||||
|
||||
88
web/landing/tailwind.config.mjs
Normal file
@@ -0,0 +1,88 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: ['./src/**/*.{astro,html,js,jsx,md,mdx,svelte,ts,tsx,vue}'],
|
||||
darkMode: 'media', // Detecta automáticamente las preferencias del sistema
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
// Colores basados en el logo teal/cyan
|
||||
primary: {
|
||||
50: '#ecfeff',
|
||||
100: '#cffafe',
|
||||
200: '#a5f3fc',
|
||||
300: '#67e8f9',
|
||||
400: '#22d3ee',
|
||||
500: '#06b6d4', // Teal principal
|
||||
600: '#0891b2',
|
||||
700: '#0e7490',
|
||||
800: '#155e75',
|
||||
900: '#164e63',
|
||||
950: '#083344',
|
||||
},
|
||||
teal: {
|
||||
50: '#f0fdfa',
|
||||
100: '#ccfbf1',
|
||||
200: '#99f6e4',
|
||||
300: '#5eead4',
|
||||
400: '#2dd4bf',
|
||||
500: '#14b8a6',
|
||||
600: '#0d9488',
|
||||
700: '#0f766e',
|
||||
800: '#115e59',
|
||||
900: '#134e4a',
|
||||
},
|
||||
gray: {
|
||||
50: '#f9fafb',
|
||||
100: '#f3f4f6',
|
||||
200: '#e5e7eb',
|
||||
300: '#d1d5db',
|
||||
400: '#9ca3af',
|
||||
500: '#6b7280',
|
||||
600: '#4b5563',
|
||||
700: '#374151',
|
||||
800: '#1f2937',
|
||||
900: '#111827',
|
||||
950: '#030712',
|
||||
},
|
||||
},
|
||||
fontFamily: {
|
||||
sans: ['Inter', 'system-ui', '-apple-system', 'BlinkMacSystemFont', 'Segoe UI', 'Roboto', 'sans-serif'],
|
||||
},
|
||||
animation: {
|
||||
'fade-in': 'fadeIn 0.6s ease-out',
|
||||
'slide-up': 'slideUp 0.6s ease-out',
|
||||
'slide-in-right': 'slideInRight 0.6s ease-out',
|
||||
'float': 'float 6s ease-in-out infinite',
|
||||
'gradient': 'gradient 15s ease infinite',
|
||||
},
|
||||
keyframes: {
|
||||
fadeIn: {
|
||||
'0%': { opacity: '0' },
|
||||
'100%': { opacity: '1' },
|
||||
},
|
||||
slideUp: {
|
||||
'0%': { transform: 'translateY(20px)', opacity: '0' },
|
||||
'100%': { transform: 'translateY(0)', opacity: '1' },
|
||||
},
|
||||
slideInRight: {
|
||||
'0%': { transform: 'translateX(-20px)', opacity: '0' },
|
||||
'100%': { transform: 'translateX(0)', opacity: '1' },
|
||||
},
|
||||
float: {
|
||||
'0%, 100%': { transform: 'translateY(0px)' },
|
||||
'50%': { transform: 'translateY(-20px)' },
|
||||
},
|
||||
gradient: {
|
||||
'0%, 100%': { backgroundPosition: '0% 50%' },
|
||||
'50%': { backgroundPosition: '100% 50%' },
|
||||
},
|
||||
},
|
||||
backgroundImage: {
|
||||
'gradient-radial': 'radial-gradient(var(--tw-gradient-stops))',
|
||||
'gradient-conic': 'conic-gradient(from 180deg at 50% 50%, var(--tw-gradient-stops))',
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
||||
|
||||
8
web/landing/tsconfig.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "astro/tsconfigs/strict",
|
||||
"compilerOptions": {
|
||||
"jsx": "react-jsx",
|
||||
"jsxImportSource": "react"
|
||||
}
|
||||
}
|
||||
|
||||
76
web/nginx.conf
Normal file
@@ -0,0 +1,76 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name localhost;
|
||||
|
||||
# Gzip compression
|
||||
gzip on;
|
||||
gzip_vary on;
|
||||
gzip_min_length 1024;
|
||||
gzip_types text/plain text/css text/xml text/javascript application/x-javascript application/xml+rss application/json application/javascript;
|
||||
|
||||
# API proxy - debe ir antes de otras rutas
|
||||
location /api {
|
||||
proxy_pass http://backend:3001;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection 'upgrade';
|
||||
proxy_set_header Host $host;
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
# WebSocket proxy
|
||||
location /ws {
|
||||
proxy_pass http://backend:3001;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
# Assets estáticos del dashboard
|
||||
location ~ ^/dashboard/.*\.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
|
||||
rewrite ^/dashboard/?(.*)$ /$1 break;
|
||||
proxy_pass http://dashboard:80;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
}
|
||||
|
||||
# Dashboard Vue - reescribir /dashboard/* a /* en el contenedor
|
||||
location /dashboard/ {
|
||||
# Quitar el prefijo /dashboard antes de pasar al contenedor
|
||||
rewrite ^/dashboard/?(.*)$ /$1 break;
|
||||
|
||||
proxy_pass http://dashboard:80;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
# Assets estáticos del landing (raíz)
|
||||
location ~ ^/.*\.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
|
||||
proxy_pass http://landing:80;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
}
|
||||
|
||||
# Landing page (Astro) - raíz
|
||||
location / {
|
||||
proxy_pass http://landing:80;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
}
|
||||
|
||||
44
web/start.sh
@@ -1,6 +1,6 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Script para iniciar el servidor web de Wallabicher
|
||||
# Script para iniciar el servidor web de Wallabicher (con landing)
|
||||
|
||||
echo "🚀 Iniciando Wallabicher Web Interface..."
|
||||
echo ""
|
||||
@@ -25,10 +25,18 @@ if [ ! -d "backend/node_modules" ]; then
|
||||
cd ..
|
||||
fi
|
||||
|
||||
# Instalar dependencias del frontend si no existen
|
||||
if [ ! -d "frontend/node_modules" ]; then
|
||||
echo "📦 Instalando dependencias del frontend..."
|
||||
cd frontend
|
||||
# Instalar dependencias del dashboard si no existen
|
||||
if [ ! -d "dashboard/node_modules" ]; then
|
||||
echo "📦 Instalando dependencias del dashboard..."
|
||||
cd dashboard
|
||||
npm install
|
||||
cd ..
|
||||
fi
|
||||
|
||||
# Instalar dependencias del landing si no existen
|
||||
if [ -d "landing" ] && [ ! -d "landing/node_modules" ]; then
|
||||
echo "📦 Instalando dependencias del landing..."
|
||||
cd landing
|
||||
npm install
|
||||
cd ..
|
||||
fi
|
||||
@@ -43,21 +51,33 @@ cd ..
|
||||
# Esperar un poco para que el backend se inicie
|
||||
sleep 2
|
||||
|
||||
# Iniciar frontend
|
||||
echo "🎨 Iniciando frontend..."
|
||||
cd frontend
|
||||
# Iniciar dashboard en background
|
||||
echo "🎨 Iniciando dashboard..."
|
||||
cd dashboard
|
||||
npm run dev &
|
||||
FRONTEND_PID=$!
|
||||
DASHBOARD_PID=$!
|
||||
cd ..
|
||||
|
||||
# Iniciar landing si existe
|
||||
if [ -d "landing" ]; then
|
||||
echo "🌐 Iniciando landing..."
|
||||
cd landing
|
||||
npm run dev &
|
||||
LANDING_PID=$!
|
||||
cd ..
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "✅ Servidores iniciados!"
|
||||
echo "📡 Backend: http://localhost:3001"
|
||||
echo "🎨 Frontend: http://localhost:3000"
|
||||
echo "🎨 Dashboard: http://localhost:3000"
|
||||
if [ -d "landing" ]; then
|
||||
echo "🌐 Landing: http://localhost:3002"
|
||||
fi
|
||||
echo ""
|
||||
echo "Presiona Ctrl+C para detener los servidores"
|
||||
|
||||
# Esperar a que se presione Ctrl+C
|
||||
trap "kill $BACKEND_PID $FRONTEND_PID 2>/dev/null; exit" INT TERM
|
||||
# Esperar a que se presione Ctrl+C y matar todos los procesos
|
||||
trap "kill $BACKEND_PID $DASHBOARD_PID ${LANDING_PID:-} 2>/dev/null; exit" INT TERM
|
||||
wait
|
||||
|
||||
|
||||