12 Commits

Author SHA1 Message Date
Omar Sánchez Pizarro
9667ebc83d activity
Signed-off-by: Omar Sánchez Pizarro <omar.sanchez@pistacero.net>
2026-01-21 10:31:38 +01:00
Omar Sánchez Pizarro
7289ad6c26 activity
Signed-off-by: Omar Sánchez Pizarro <omar.sanchez@pistacero.net>
2026-01-21 10:31:06 +01:00
Omar Sánchez Pizarro
c72ef29319 Add Stripe payment integration and update configuration
- Added Stripe environment variables to docker-compose.yml for secret key, webhook secret, and base URL.
- Created PAYMENTS.md to document the setup and usage of the Stripe payment system.
- Updated webhook signature handling in stripe.js to use a new secret key.

Signed-off-by: Omar Sánchez Pizarro <omar.sanchez@pistacero.net>
2026-01-21 10:18:34 +01:00
Omar Sánchez Pizarro
cc6ffdc5a5 payments with stripe
Signed-off-by: Omar Sánchez Pizarro <omar.sanchez@pistacero.net>
2026-01-21 02:20:13 +01:00
Omar Sánchez Pizarro
626e3342d0 fix on nginx and astro
Signed-off-by: Omar Sánchez Pizarro <omar.sanchez@pistacero.net>
2026-01-21 01:09:57 +01:00
Omar Sánchez Pizarro
3acad140c5 fix: nginx
Signed-off-by: Omar Sánchez Pizarro <omar.sanchez@pistacero.net>
2026-01-21 00:53:52 +01:00
Omar Sánchez Pizarro
905966d548 fix
Signed-off-by: Omar Sánchez Pizarro <omar.sanchez@pistacero.net>
2026-01-21 00:31:05 +01:00
Omar Sánchez Pizarro
53928328d4 refactor nginx
Signed-off-by: Omar Sánchez Pizarro <omar.sanchez@pistacero.net>
2026-01-21 00:30:13 +01:00
Omar Sánchez Pizarro
ed2107086c Refactor routing in App.vue and Login.vue to redirect to the home page instead of the dashboard. Remove unused Users.vue component to streamline the codebase. Update Dockerfile to adjust Nginx configuration for landing page deployment. 2026-01-21 00:16:26 +01:00
Omar Sánchez Pizarro
c1716b2193 Update docker-compose.yml to change landing page build context from ./landing to ./web/landing 2026-01-20 23:58:24 +01:00
Omar Sánchez Pizarro
447ff6a4d6 Enhance web start script to support landing page and add user management view 2026-01-20 23:57:47 +01:00
Omar Sánchez Pizarro
6ec8855c00 add landing and subscription plans
Signed-off-by: Omar Sánchez Pizarro <omar.sanchez@pistacero.net>
2026-01-20 23:49:19 +01:00
97 changed files with 12603 additions and 858 deletions

View File

@@ -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
View File

@@ -177,5 +177,5 @@ yarn-debug.log*
yarn-error.log*
# Web build
web/frontend/dist/
web/frontend/.vite/
web/dashboard/dist/
web/dashboard/.vite/

View File

@@ -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
View 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
View 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)

View File

@@ -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) |

View File

@@ -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
View File

@@ -0,0 +1 @@
/// <reference types="astro/client" />

1
landing/src/env.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
/// <reference path="../.astro/types.d.ts" />

View File

@@ -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
View 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
View 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/`

View File

@@ -10,9 +10,9 @@ cd web/backend
npm install
```
**Frontend:**
**Dashboard:**
```bash
cd web/frontend
cd web/dashboard
npm install
```

View File

@@ -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

View 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;
}

View 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=

View 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' });
}
}

View File

@@ -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",

View File

@@ -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"

View File

@@ -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,
},
});

View 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;

View 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;

View File

@@ -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;

View File

@@ -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) {

View File

@@ -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() {

View File

@@ -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) {

View 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);
}

View File

@@ -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;
}
}

View File

@@ -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
View 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;"]

View 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
View 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;
}
}

View File

@@ -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",

View File

@@ -1,5 +1,5 @@
{
"name": "wallabicher-frontend",
"name": "wallabicher-dashboard",
"version": "1.0.0",
"type": "module",
"scripts": {

View File

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 25 KiB

View File

Before

Width:  |  Height:  |  Size: 118 KiB

After

Width:  |  Height:  |  Size: 118 KiB

View File

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 22 KiB

View File

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 1.6 KiB

View File

Before

Width:  |  Height:  |  Size: 2.4 KiB

After

Width:  |  Height:  |  Size: 2.4 KiB

View File

Before

Width:  |  Height:  |  Size: 5.6 KiB

After

Width:  |  Height:  |  Size: 5.6 KiB

View File

Before

Width:  |  Height:  |  Size: 39 KiB

After

Width:  |  Height:  |  Size: 39 KiB

View File

@@ -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/')
);
}
});

View File

@@ -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>

View 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>

View File

@@ -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) {

View File

@@ -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;
},
};

View File

@@ -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,

View File

@@ -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;

View 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>

View File

@@ -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';

View 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>

View 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>

View 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"
>
</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>

View File

@@ -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,
},
});
}));

View File

@@ -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";
}
}

View File

@@ -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"
>
</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
View 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

View File

@@ -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
View 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.

View 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
View 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

File diff suppressed because it is too large Load Diff

18
web/landing/package.json Normal file
View 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"
}
}

View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

View 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>

View 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>

View 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>

View 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>

View 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>

View 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
View File

@@ -0,0 +1 @@
/// <reference path="../.astro/types.d.ts" />

View 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>

View 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>

View 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: [],
}

View File

@@ -0,0 +1,8 @@
{
"extends": "astro/tsconfigs/strict",
"compilerOptions": {
"jsx": "react-jsx",
"jsxImportSource": "react"
}
}

76
web/nginx.conf Normal file
View 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;
}
}

View File

@@ -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