add landing and subscription plans
Signed-off-by: Omar Sánchez Pizarro <omar.sanchez@pistacero.net>
@@ -18,8 +18,8 @@ yarn-debug.log*
|
|||||||
yarn-error.log*
|
yarn-error.log*
|
||||||
|
|
||||||
# Web
|
# Web
|
||||||
web/frontend/dist/
|
web/dashboard/dist/
|
||||||
web/frontend/.vite/
|
web/dashboard/.vite/
|
||||||
|
|
||||||
# IDE
|
# IDE
|
||||||
.vscode/
|
.vscode/
|
||||||
|
|||||||
4
.gitignore
vendored
@@ -177,5 +177,5 @@ yarn-debug.log*
|
|||||||
yarn-error.log*
|
yarn-error.log*
|
||||||
|
|
||||||
# Web build
|
# Web build
|
||||||
web/frontend/dist/
|
web/dashboard/dist/
|
||||||
web/frontend/.vite/
|
web/dashboard/.vite/
|
||||||
|
|||||||
10
DOCKER.md
@@ -31,7 +31,7 @@ docker-compose up -d
|
|||||||
Esto iniciará:
|
Esto iniciará:
|
||||||
- **Redis** (puerto 6379) - Cache de artículos
|
- **Redis** (puerto 6379) - Cache de artículos
|
||||||
- **Backend** (puerto 3001) - API Node.js
|
- **Backend** (puerto 3001) - API Node.js
|
||||||
- **Frontend** (puerto 3000) - Interfaz web Vue
|
- **Dashboard** (puerto 3000) - Interfaz web Vue
|
||||||
- **Wallabicher Python** - Servicio principal de monitoreo
|
- **Wallabicher Python** - Servicio principal de monitoreo
|
||||||
|
|
||||||
### 3. Acceder a la interfaz
|
### 3. Acceder a la interfaz
|
||||||
@@ -51,7 +51,7 @@ Abre tu navegador en: **http://localhost:3000**
|
|||||||
- **WebSocket**: ws://localhost:3001
|
- **WebSocket**: ws://localhost:3001
|
||||||
- **Funciones**: API REST y WebSockets para la interfaz web
|
- **Funciones**: API REST y WebSockets para la interfaz web
|
||||||
|
|
||||||
### Frontend (Vue + Nginx)
|
### Dashboard (Vue + Nginx)
|
||||||
- **Puerto**: 3000
|
- **Puerto**: 3000
|
||||||
- **URL**: http://localhost:3000
|
- **URL**: http://localhost:3000
|
||||||
- **Funciones**: Interfaz web moderna
|
- **Funciones**: Interfaz web moderna
|
||||||
@@ -70,7 +70,7 @@ docker-compose logs -f
|
|||||||
# Servicio específico
|
# Servicio específico
|
||||||
docker-compose logs -f wallabicher
|
docker-compose logs -f wallabicher
|
||||||
docker-compose logs -f backend
|
docker-compose logs -f backend
|
||||||
docker-compose logs -f frontend
|
docker-compose logs -f dashboard
|
||||||
```
|
```
|
||||||
|
|
||||||
### Detener servicios
|
### Detener servicios
|
||||||
@@ -158,11 +158,11 @@ rm -rf monitor.log
|
|||||||
mkdir -p logs
|
mkdir -p logs
|
||||||
```
|
```
|
||||||
|
|
||||||
### El frontend no carga
|
### El dashboard no carga
|
||||||
|
|
||||||
Verifica los logs:
|
Verifica los logs:
|
||||||
```bash
|
```bash
|
||||||
docker-compose logs frontend
|
docker-compose logs dashboard
|
||||||
```
|
```
|
||||||
|
|
||||||
### Reconstruir todo desde cero
|
### Reconstruir todo desde cero
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ Abre: **http://localhost:3000**
|
|||||||
|
|
||||||
| Servicio | Puerto | Descripción |
|
| Servicio | Puerto | Descripción |
|
||||||
|----------|--------|-------------|
|
|----------|--------|-------------|
|
||||||
| **Frontend** | 3000 | Interfaz web Vue |
|
| **Dashboard** | 3000 | Interfaz web Vue |
|
||||||
| **Backend** | 3001 | API Node.js |
|
| **Backend** | 3001 | API Node.js |
|
||||||
| **Redis** | 6379 | Cache de artículos |
|
| **Redis** | 6379 | Cache de artículos |
|
||||||
| **Wallabicher** | - | Servicio Python (interno) |
|
| **Wallabicher** | - | Servicio Python (interno) |
|
||||||
|
|||||||
@@ -55,18 +55,46 @@ services:
|
|||||||
timeout: 10s
|
timeout: 10s
|
||||||
retries: 3
|
retries: 3
|
||||||
|
|
||||||
# Frontend Vue
|
# Dashboard Vue
|
||||||
frontend:
|
dashboard:
|
||||||
build:
|
build:
|
||||||
context: ./web/frontend
|
context: ./web/dashboard
|
||||||
dockerfile: Dockerfile
|
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:
|
depends_on:
|
||||||
- backend
|
- backend
|
||||||
networks:
|
networks:
|
||||||
- wallabicher-network
|
- wallabicher-network
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
|
# Landing page (Astro)
|
||||||
|
landing:
|
||||||
|
build:
|
||||||
|
context: ./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)
|
# Servicio Python principal (Wallabicher)
|
||||||
# NOTA: Para usar MongoDB, asegúrate de que config.yaml tenga:
|
# NOTA: Para usar MongoDB, asegúrate de que config.yaml tenga:
|
||||||
# cache:
|
# cache:
|
||||||
|
|||||||
1
landing/.astro/types.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
/// <reference types="astro/client" />
|
||||||
1
landing/src/env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
/// <reference path="../.astro/types.d.ts" />
|
||||||
@@ -20,10 +20,10 @@
|
|||||||
"docker:clean": "docker compose down -v && docker system prune -f",
|
"docker:clean": "docker compose down -v && docker system prune -f",
|
||||||
"backend:dev": "cd web/backend && npm run dev",
|
"backend:dev": "cd web/backend && npm run dev",
|
||||||
"backend:start": "cd web/backend && npm start",
|
"backend:start": "cd web/backend && npm start",
|
||||||
"frontend:dev": "cd web/frontend && npm run dev",
|
"dashboard:dev": "cd web/dashboard && npm run dev",
|
||||||
"frontend:build": "cd web/frontend && npm run build",
|
"dashboard:build": "cd web/dashboard && npm run build",
|
||||||
"frontend:preview": "cd web/frontend && npm run preview",
|
"dashboard:preview": "cd web/dashboard && npm run preview",
|
||||||
"install:all": "cd web/backend && npm install && cd ../frontend && npm install",
|
"install:all": "cd web/backend && npm install && cd ../dashboard && npm install",
|
||||||
"git:status": "git status",
|
"git:status": "git status",
|
||||||
"git:pull": "git pull",
|
"git:pull": "git pull",
|
||||||
"git:push": "git push",
|
"git:push": "git push",
|
||||||
|
|||||||
@@ -10,9 +10,9 @@ cd web/backend
|
|||||||
npm install
|
npm install
|
||||||
```
|
```
|
||||||
|
|
||||||
**Frontend:**
|
**Dashboard:**
|
||||||
```bash
|
```bash
|
||||||
cd web/frontend
|
cd web/dashboard
|
||||||
npm install
|
npm install
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
120
web/backend/config/subscriptionPlans.js
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
// Planes de suscripción con sus límites y precios
|
||||||
|
export const SUBSCRIPTION_PLANS = {
|
||||||
|
free: {
|
||||||
|
id: 'free',
|
||||||
|
name: 'Gratis',
|
||||||
|
description: 'Perfecto para empezar',
|
||||||
|
price: {
|
||||||
|
monthly: 0,
|
||||||
|
yearly: 0,
|
||||||
|
},
|
||||||
|
limits: {
|
||||||
|
maxWorkers: 2, // Número máximo de búsquedas/workers
|
||||||
|
maxNotificationsPerDay: 50,
|
||||||
|
platforms: ['wallapop'], // Solo Wallapop
|
||||||
|
},
|
||||||
|
features: [
|
||||||
|
'Hasta 2 búsquedas simultáneas',
|
||||||
|
'Solo Wallapop',
|
||||||
|
'50 notificaciones por día',
|
||||||
|
'Soporte por email',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
basic: {
|
||||||
|
id: 'basic',
|
||||||
|
name: 'Básico',
|
||||||
|
description: 'Para usuarios ocasionales',
|
||||||
|
price: {
|
||||||
|
monthly: 9.99,
|
||||||
|
yearly: 99.99, // ~17% descuento
|
||||||
|
},
|
||||||
|
limits: {
|
||||||
|
maxWorkers: 5,
|
||||||
|
maxNotificationsPerDay: 200,
|
||||||
|
platforms: ['wallapop', 'vinted'],
|
||||||
|
},
|
||||||
|
features: [
|
||||||
|
'Hasta 5 búsquedas simultáneas',
|
||||||
|
'Wallapop y Vinted',
|
||||||
|
'200 notificaciones por día',
|
||||||
|
'Soporte prioritario',
|
||||||
|
'Sin límite de favoritos',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
pro: {
|
||||||
|
id: 'pro',
|
||||||
|
name: 'Pro',
|
||||||
|
description: 'Para usuarios avanzados',
|
||||||
|
price: {
|
||||||
|
monthly: 19.99,
|
||||||
|
yearly: 199.99, // ~17% descuento
|
||||||
|
},
|
||||||
|
limits: {
|
||||||
|
maxWorkers: 15,
|
||||||
|
maxNotificationsPerDay: 1000,
|
||||||
|
platforms: ['wallapop', 'vinted', 'buyee'],
|
||||||
|
},
|
||||||
|
features: [
|
||||||
|
'Hasta 15 búsquedas simultáneas',
|
||||||
|
'Todas las plataformas',
|
||||||
|
'1000 notificaciones por día',
|
||||||
|
'Soporte prioritario 24/7',
|
||||||
|
'API access',
|
||||||
|
'Webhooks personalizados',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
enterprise: {
|
||||||
|
id: 'enterprise',
|
||||||
|
name: 'Enterprise',
|
||||||
|
description: 'Para equipos y uso intensivo',
|
||||||
|
price: {
|
||||||
|
monthly: 49.99,
|
||||||
|
yearly: 499.99, // ~17% descuento
|
||||||
|
},
|
||||||
|
limits: {
|
||||||
|
maxWorkers: -1, // Ilimitado
|
||||||
|
maxNotificationsPerDay: -1, // Ilimitado
|
||||||
|
platforms: ['wallapop', 'vinted', 'buyee'], // Todas
|
||||||
|
},
|
||||||
|
features: [
|
||||||
|
'Búsquedas ilimitadas',
|
||||||
|
'Notificaciones ilimitadas',
|
||||||
|
'Todas las plataformas',
|
||||||
|
'Soporte dedicado',
|
||||||
|
'API completa',
|
||||||
|
'Webhooks personalizados',
|
||||||
|
'Gestión de múltiples usuarios',
|
||||||
|
'Estadísticas avanzadas',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Obtener plan por ID
|
||||||
|
export function getPlan(planId) {
|
||||||
|
return SUBSCRIPTION_PLANS[planId] || SUBSCRIPTION_PLANS.free;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verificar si un plan tiene una característica
|
||||||
|
export function hasFeature(planId, feature) {
|
||||||
|
const plan = getPlan(planId);
|
||||||
|
return plan.features.includes(feature);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verificar si un plan tiene acceso a una plataforma
|
||||||
|
export function hasPlatformAccess(planId, platform) {
|
||||||
|
const plan = getPlan(planId);
|
||||||
|
return plan.limits.platforms.includes(platform) || plan.limits.platforms.length === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Obtener límite de workers para un plan
|
||||||
|
export function getMaxWorkers(planId) {
|
||||||
|
const plan = getPlan(planId);
|
||||||
|
return plan.limits.maxWorkers;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Obtener límite de notificaciones diarias
|
||||||
|
export function getMaxNotificationsPerDay(planId) {
|
||||||
|
const plan = getPlan(planId);
|
||||||
|
return plan.limits.maxNotificationsPerDay;
|
||||||
|
}
|
||||||
|
|
||||||
133
web/backend/middlewares/subscriptionLimits.js
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
import { getUser, getUserSubscription, getWorkerCount } from '../services/mongodb.js';
|
||||||
|
import { getPlan, getMaxWorkers, hasPlatformAccess } from '../config/subscriptionPlans.js';
|
||||||
|
|
||||||
|
// Middleware para verificar límites de suscripción
|
||||||
|
export async function checkSubscriptionLimits(req, res, next) {
|
||||||
|
try {
|
||||||
|
const username = req.user?.username;
|
||||||
|
|
||||||
|
if (!username) {
|
||||||
|
return res.status(401).json({ error: 'Authentication required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Obtener usuario y suscripción
|
||||||
|
const user = await getUser(username);
|
||||||
|
if (!user) {
|
||||||
|
return res.status(404).json({ error: 'Usuario no encontrado' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Si es admin, no aplicar límites
|
||||||
|
if (user.role === 'admin') {
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
|
||||||
|
const subscription = await getUserSubscription(username);
|
||||||
|
const planId = subscription?.planId || 'free';
|
||||||
|
const plan = getPlan(planId);
|
||||||
|
|
||||||
|
// Añadir información del plan al request para uso posterior
|
||||||
|
req.userPlan = {
|
||||||
|
planId,
|
||||||
|
plan,
|
||||||
|
subscription,
|
||||||
|
};
|
||||||
|
|
||||||
|
next();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error verificando límites de suscripción:', error);
|
||||||
|
res.status(500).json({ error: 'Error verificando límites de suscripción' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Middleware para verificar límite de workers
|
||||||
|
export async function checkWorkerLimit(req, res, next) {
|
||||||
|
try {
|
||||||
|
const username = req.user?.username;
|
||||||
|
const userPlan = req.userPlan;
|
||||||
|
|
||||||
|
if (!userPlan) {
|
||||||
|
// Si no hay userPlan, ejecutar checkSubscriptionLimits primero
|
||||||
|
await checkSubscriptionLimits(req, res, () => {});
|
||||||
|
if (res.headersSent) return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const planId = req.userPlan.planId;
|
||||||
|
const maxWorkers = getMaxWorkers(planId);
|
||||||
|
|
||||||
|
// Si es ilimitado (-1), permitir
|
||||||
|
if (maxWorkers === -1) {
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Obtener número actual de workers
|
||||||
|
const currentWorkerCount = await getWorkerCount(username);
|
||||||
|
|
||||||
|
// Si estamos actualizando workers, verificar el nuevo número
|
||||||
|
if (req.method === 'PUT' && req.body?.items) {
|
||||||
|
const newWorkerCount = (req.body.items || []).filter(
|
||||||
|
(item, index) => !(req.body.disabled || []).includes(index)
|
||||||
|
).length;
|
||||||
|
|
||||||
|
if (newWorkerCount > maxWorkers) {
|
||||||
|
return res.status(403).json({
|
||||||
|
error: 'Límite de búsquedas excedido',
|
||||||
|
message: `Tu plan actual (${req.userPlan.plan.name}) permite hasta ${maxWorkers} búsquedas simultáneas. Estás intentando crear ${newWorkerCount}.`,
|
||||||
|
currentPlan: planId,
|
||||||
|
maxWorkers,
|
||||||
|
currentCount: currentWorkerCount,
|
||||||
|
requestedCount: newWorkerCount,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else if (currentWorkerCount >= maxWorkers) {
|
||||||
|
// Si ya alcanzó el límite y está intentando crear más
|
||||||
|
return res.status(403).json({
|
||||||
|
error: 'Límite de búsquedas alcanzado',
|
||||||
|
message: `Tu plan actual (${req.userPlan.plan.name}) permite hasta ${maxWorkers} búsquedas simultáneas.`,
|
||||||
|
currentPlan: planId,
|
||||||
|
maxWorkers,
|
||||||
|
currentCount: currentWorkerCount,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
next();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error verificando límite de workers:', error);
|
||||||
|
res.status(500).json({ error: 'Error verificando límite de workers' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Middleware para verificar acceso a plataforma
|
||||||
|
export async function checkPlatformAccess(req, res, next) {
|
||||||
|
try {
|
||||||
|
const platform = req.body?.platform || req.query?.platform;
|
||||||
|
|
||||||
|
if (!platform) {
|
||||||
|
return next(); // Si no hay plataforma especificada, continuar
|
||||||
|
}
|
||||||
|
|
||||||
|
const userPlan = req.userPlan;
|
||||||
|
|
||||||
|
if (!userPlan) {
|
||||||
|
await checkSubscriptionLimits(req, res, () => {});
|
||||||
|
if (res.headersSent) return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const planId = req.userPlan.planId;
|
||||||
|
|
||||||
|
if (!hasPlatformAccess(planId, platform)) {
|
||||||
|
return res.status(403).json({
|
||||||
|
error: 'Plataforma no disponible en tu plan',
|
||||||
|
message: `La plataforma "${platform}" no está disponible en tu plan actual (${req.userPlan.plan.name}).`,
|
||||||
|
currentPlan: planId,
|
||||||
|
requestedPlatform: platform,
|
||||||
|
availablePlatforms: req.userPlan.plan.limits.platforms,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
next();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error verificando acceso a plataforma:', error);
|
||||||
|
res.status(500).json({ error: 'Error verificando acceso a plataforma' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
208
web/backend/routes/subscription.js
Normal file
@@ -0,0 +1,208 @@
|
|||||||
|
import express from 'express';
|
||||||
|
import { basicAuthMiddleware } from '../middlewares/auth.js';
|
||||||
|
import { adminAuthMiddleware } from '../middlewares/adminAuth.js';
|
||||||
|
import { getUser, getUserSubscription, updateUserSubscription, getWorkerCount } from '../services/mongodb.js';
|
||||||
|
import { SUBSCRIPTION_PLANS, getPlan } from '../config/subscriptionPlans.js';
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
// Obtener planes disponibles (público)
|
||||||
|
router.get('/plans', (req, res) => {
|
||||||
|
try {
|
||||||
|
const plans = Object.values(SUBSCRIPTION_PLANS).map(plan => ({
|
||||||
|
id: plan.id,
|
||||||
|
name: plan.name,
|
||||||
|
description: plan.description,
|
||||||
|
price: plan.price,
|
||||||
|
limits: {
|
||||||
|
maxWorkers: plan.limits.maxWorkers,
|
||||||
|
maxNotificationsPerDay: plan.limits.maxNotificationsPerDay,
|
||||||
|
platforms: plan.limits.platforms,
|
||||||
|
},
|
||||||
|
features: plan.features,
|
||||||
|
}));
|
||||||
|
|
||||||
|
res.json({ plans });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error obteniendo planes:', error);
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Obtener suscripción del usuario actual
|
||||||
|
router.get('/me', basicAuthMiddleware, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const username = req.user.username;
|
||||||
|
const subscription = await getUserSubscription(username);
|
||||||
|
const user = await getUser(username);
|
||||||
|
const workerCount = await getWorkerCount(username);
|
||||||
|
|
||||||
|
const planId = subscription?.planId || 'free';
|
||||||
|
const plan = getPlan(planId);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
subscription: {
|
||||||
|
planId,
|
||||||
|
plan: {
|
||||||
|
id: plan.id,
|
||||||
|
name: plan.name,
|
||||||
|
description: plan.description,
|
||||||
|
price: plan.price,
|
||||||
|
limits: plan.limits,
|
||||||
|
features: plan.features,
|
||||||
|
},
|
||||||
|
status: subscription?.status || 'active',
|
||||||
|
currentPeriodStart: subscription?.currentPeriodStart || user?.createdAt,
|
||||||
|
currentPeriodEnd: subscription?.currentPeriodEnd || null,
|
||||||
|
cancelAtPeriodEnd: subscription?.cancelAtPeriodEnd || false,
|
||||||
|
},
|
||||||
|
usage: {
|
||||||
|
workers: workerCount,
|
||||||
|
maxWorkers: plan.limits.maxWorkers === -1 ? 'Ilimitado' : plan.limits.maxWorkers,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error obteniendo suscripción:', error);
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Actualizar suscripción (requiere admin o para el propio usuario en caso de cancelación)
|
||||||
|
router.put('/me', basicAuthMiddleware, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const username = req.user.username;
|
||||||
|
const { planId, status, cancelAtPeriodEnd } = req.body;
|
||||||
|
|
||||||
|
if (!planId) {
|
||||||
|
return res.status(400).json({ error: 'planId es requerido' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verificar que el plan existe
|
||||||
|
const plan = getPlan(planId);
|
||||||
|
if (!plan) {
|
||||||
|
return res.status(400).json({ error: 'Plan no válido' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Solo permitir actualizar a plan gratuito o cancelar suscripción
|
||||||
|
// Para actualizar a planes de pago, se requiere integración con pasarela de pago
|
||||||
|
if (planId !== 'free' && !req.user.role === 'admin') {
|
||||||
|
return res.status(403).json({
|
||||||
|
error: 'Para actualizar a un plan de pago, contacta con soporte o usa la pasarela de pago'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const subscription = await getUserSubscription(username);
|
||||||
|
|
||||||
|
// Calcular fechas del período
|
||||||
|
const now = new Date();
|
||||||
|
let currentPeriodStart = subscription?.currentPeriodStart || now;
|
||||||
|
let currentPeriodEnd = null;
|
||||||
|
|
||||||
|
if (planId !== 'free') {
|
||||||
|
// Para planes de pago, establecer período mensual o anual según corresponda
|
||||||
|
// Por ahora, asumimos mensual (30 días)
|
||||||
|
currentPeriodEnd = new Date(now);
|
||||||
|
currentPeriodEnd.setMonth(currentPeriodEnd.getMonth() + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
await updateUserSubscription(username, {
|
||||||
|
planId,
|
||||||
|
status: status || 'active',
|
||||||
|
currentPeriodStart,
|
||||||
|
currentPeriodEnd,
|
||||||
|
cancelAtPeriodEnd: cancelAtPeriodEnd || false,
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`✅ Suscripción actualizada para ${username}: ${planId}`);
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message: 'Suscripción actualizada correctamente',
|
||||||
|
subscription: {
|
||||||
|
planId,
|
||||||
|
status: status || 'active',
|
||||||
|
currentPeriodStart,
|
||||||
|
currentPeriodEnd,
|
||||||
|
cancelAtPeriodEnd: cancelAtPeriodEnd || false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error actualizando suscripción:', error);
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Obtener suscripción de cualquier usuario (solo admin)
|
||||||
|
router.get('/:username', basicAuthMiddleware, adminAuthMiddleware, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { username } = req.params;
|
||||||
|
const subscription = await getUserSubscription(username);
|
||||||
|
const user = await getUser(username);
|
||||||
|
const workerCount = await getWorkerCount(username);
|
||||||
|
|
||||||
|
const planId = subscription?.planId || 'free';
|
||||||
|
const plan = getPlan(planId);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
subscription: {
|
||||||
|
planId,
|
||||||
|
plan: {
|
||||||
|
id: plan.id,
|
||||||
|
name: plan.name,
|
||||||
|
description: plan.description,
|
||||||
|
price: plan.price,
|
||||||
|
limits: plan.limits,
|
||||||
|
features: plan.features,
|
||||||
|
},
|
||||||
|
status: subscription?.status || 'active',
|
||||||
|
currentPeriodStart: subscription?.currentPeriodStart || user?.createdAt,
|
||||||
|
currentPeriodEnd: subscription?.currentPeriodEnd || null,
|
||||||
|
cancelAtPeriodEnd: subscription?.cancelAtPeriodEnd || false,
|
||||||
|
},
|
||||||
|
usage: {
|
||||||
|
workers: workerCount,
|
||||||
|
maxWorkers: plan.limits.maxWorkers === -1 ? 'Ilimitado' : plan.limits.maxWorkers,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error obteniendo suscripción:', error);
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Actualizar suscripción de cualquier usuario (solo admin)
|
||||||
|
router.put('/:username', basicAuthMiddleware, adminAuthMiddleware, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { username } = req.params;
|
||||||
|
const { planId, status, currentPeriodStart, currentPeriodEnd, cancelAtPeriodEnd } = req.body;
|
||||||
|
|
||||||
|
if (!planId) {
|
||||||
|
return res.status(400).json({ error: 'planId es requerido' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verificar que el plan existe
|
||||||
|
const plan = getPlan(planId);
|
||||||
|
if (!plan) {
|
||||||
|
return res.status(400).json({ error: 'Plan no válido' });
|
||||||
|
}
|
||||||
|
|
||||||
|
await updateUserSubscription(username, {
|
||||||
|
planId,
|
||||||
|
status: status || 'active',
|
||||||
|
currentPeriodStart: currentPeriodStart ? new Date(currentPeriodStart) : new Date(),
|
||||||
|
currentPeriodEnd: currentPeriodEnd ? new Date(currentPeriodEnd) : null,
|
||||||
|
cancelAtPeriodEnd: cancelAtPeriodEnd || false,
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`✅ Suscripción actualizada para ${username}: ${planId} por admin ${req.user.username}`);
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message: `Suscripción de ${username} actualizada correctamente`,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error actualizando suscripción:', error);
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
|
|
||||||
@@ -2,6 +2,7 @@ import express from 'express';
|
|||||||
import { basicAuthMiddleware } from '../middlewares/auth.js';
|
import { basicAuthMiddleware } from '../middlewares/auth.js';
|
||||||
import { broadcast } from '../services/websocket.js';
|
import { broadcast } from '../services/websocket.js';
|
||||||
import { getWorkers, setWorkers, getDB } from '../services/mongodb.js';
|
import { getWorkers, setWorkers, getDB } from '../services/mongodb.js';
|
||||||
|
import { checkSubscriptionLimits, checkWorkerLimit, checkPlatformAccess } from '../middlewares/subscriptionLimits.js';
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
@@ -23,7 +24,7 @@ router.get('/', basicAuthMiddleware, async (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Actualizar workers del usuario autenticado (requiere autenticación)
|
// Actualizar workers del usuario autenticado (requiere autenticación)
|
||||||
router.put('/', basicAuthMiddleware, async (req, res) => {
|
router.put('/', basicAuthMiddleware, checkSubscriptionLimits, checkWorkerLimit, checkPlatformAccess, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const db = getDB();
|
const db = getDB();
|
||||||
if (!db) {
|
if (!db) {
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import telegramRouter from './routes/telegram.js';
|
|||||||
import pushRouter from './routes/push.js';
|
import pushRouter from './routes/push.js';
|
||||||
import usersRouter from './routes/users.js';
|
import usersRouter from './routes/users.js';
|
||||||
import adminRouter from './routes/admin.js';
|
import adminRouter from './routes/admin.js';
|
||||||
|
import subscriptionRouter from './routes/subscription.js';
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
const server = createServer(app);
|
const server = createServer(app);
|
||||||
@@ -49,6 +50,7 @@ app.use('/api/telegram', telegramRouter);
|
|||||||
app.use('/api/push', pushRouter);
|
app.use('/api/push', pushRouter);
|
||||||
app.use('/api/users', usersRouter);
|
app.use('/api/users', usersRouter);
|
||||||
app.use('/api/admin', adminRouter);
|
app.use('/api/admin', adminRouter);
|
||||||
|
app.use('/api/subscription', subscriptionRouter);
|
||||||
|
|
||||||
// Inicializar servidor
|
// Inicializar servidor
|
||||||
async function startServer() {
|
async function startServer() {
|
||||||
|
|||||||
@@ -1277,6 +1277,104 @@ 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',
|
||||||
|
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
|
// Cerrar conexión
|
||||||
export async function closeMongoDB() {
|
export async function closeMongoDB() {
|
||||||
if (mongoClient) {
|
if (mongoClient) {
|
||||||
|
|||||||
@@ -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 {string} fingerprintHash - Hash del fingerprint generado en el cliente
|
||||||
* @param {Object} deviceInfo - Información del dispositivo del cliente
|
* @param {Object} deviceInfo - Información del dispositivo del cliente
|
||||||
* @param {Object} req - Request object de Express
|
* @param {Object} req - Request object de Express
|
||||||
|
|||||||
@@ -20,8 +20,8 @@ FROM nginx:alpine
|
|||||||
# Copiar archivos construidos
|
# Copiar archivos construidos
|
||||||
COPY --from=builder /app/dist /usr/share/nginx/html
|
COPY --from=builder /app/dist /usr/share/nginx/html
|
||||||
|
|
||||||
# Copiar configuración de nginx
|
# Copiar configuración de nginx (se puede sobrescribir con volumen)
|
||||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
COPY nginx-dashboard.conf /etc/nginx/conf.d/default.conf
|
||||||
|
|
||||||
EXPOSE 80
|
EXPOSE 80
|
||||||
|
|
||||||
24
web/dashboard/nginx-dashboard.conf
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
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;
|
||||||
|
|
||||||
|
# SPA routing - todas las rutas del dashboard
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Cache static assets
|
||||||
|
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
|
||||||
|
expires 1y;
|
||||||
|
add_header Cache-Control "public, immutable";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
{
|
{
|
||||||
"name": "wallabicher-frontend",
|
"name": "wallabicher-dashboard",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "wallabicher-frontend",
|
"name": "wallabicher-dashboard",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fingerprintjs/fingerprintjs": "^5.0.1",
|
"@fingerprintjs/fingerprintjs": "^5.0.1",
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"name": "wallabicher-frontend",
|
"name": "wallabicher-dashboard",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 118 KiB After Width: | Height: | Size: 118 KiB |
|
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 2.4 KiB After Width: | Height: | Size: 2.4 KiB |
|
Before Width: | Height: | Size: 5.6 KiB After Width: | Height: | Size: 5.6 KiB |
|
Before Width: | Height: | Size: 39 KiB After Width: | Height: | Size: 39 KiB |
@@ -9,7 +9,7 @@
|
|||||||
>
|
>
|
||||||
<!-- Logo -->
|
<!-- Logo -->
|
||||||
<div class="flex items-center justify-between h-16 px-4 border-b border-gray-200 dark:border-gray-700">
|
<div class="flex items-center justify-between h-16 px-4 border-b border-gray-200 dark:border-gray-700">
|
||||||
<router-link v-if="!sidebarCollapsed" to="/" class="flex items-center space-x-3 flex-1 min-w-0 group">
|
<router-link v-if="!sidebarCollapsed" to="/dashboard" class="flex items-center space-x-3 flex-1 min-w-0 group">
|
||||||
<div class="flex-shrink-0 w-10 h-10 rounded-lg overflow-hidden ring-2 ring-gray-200 dark:ring-gray-700 group-hover:ring-primary-500 transition-all">
|
<div class="flex-shrink-0 w-10 h-10 rounded-lg overflow-hidden ring-2 ring-gray-200 dark:ring-gray-700 group-hover:ring-primary-500 transition-all">
|
||||||
<img
|
<img
|
||||||
src="/logo.jpg"
|
src="/logo.jpg"
|
||||||
@@ -22,7 +22,7 @@
|
|||||||
<p class="text-xs text-gray-500 dark:text-gray-400">Admin Panel</p>
|
<p class="text-xs text-gray-500 dark:text-gray-400">Admin Panel</p>
|
||||||
</div>
|
</div>
|
||||||
</router-link>
|
</router-link>
|
||||||
<router-link v-else to="/" class="flex items-center justify-center w-full group">
|
<router-link v-else to="/dashboard" class="flex items-center justify-center w-full group">
|
||||||
<div class="w-10 h-10 rounded-lg overflow-hidden ring-2 ring-gray-200 dark:ring-gray-700 group-hover:ring-primary-500 transition-all">
|
<div class="w-10 h-10 rounded-lg overflow-hidden ring-2 ring-gray-200 dark:ring-gray-700 group-hover:ring-primary-500 transition-all">
|
||||||
<img
|
<img
|
||||||
src="/logo.jpg"
|
src="/logo.jpg"
|
||||||
@@ -164,10 +164,12 @@ import {
|
|||||||
DocumentTextIcon,
|
DocumentTextIcon,
|
||||||
HeartIcon,
|
HeartIcon,
|
||||||
Cog6ToothIcon,
|
Cog6ToothIcon,
|
||||||
|
CogIcon,
|
||||||
UserGroupIcon,
|
UserGroupIcon,
|
||||||
DocumentMagnifyingGlassIcon,
|
DocumentMagnifyingGlassIcon,
|
||||||
ShieldExclamationIcon,
|
ShieldExclamationIcon,
|
||||||
ClockIcon,
|
ClockIcon,
|
||||||
|
CreditCardIcon,
|
||||||
Bars3Icon,
|
Bars3Icon,
|
||||||
XMarkIcon,
|
XMarkIcon,
|
||||||
SunIcon,
|
SunIcon,
|
||||||
@@ -187,7 +189,9 @@ const allNavItems = [
|
|||||||
{ path: '/articles', name: 'Artículos', icon: DocumentTextIcon, adminOnly: false },
|
{ path: '/articles', name: 'Artículos', icon: DocumentTextIcon, adminOnly: false },
|
||||||
{ path: '/favorites', name: 'Favoritos', icon: HeartIcon, adminOnly: false },
|
{ path: '/favorites', name: 'Favoritos', icon: HeartIcon, adminOnly: false },
|
||||||
{ path: '/workers', name: 'Workers', icon: Cog6ToothIcon, 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: '/logs', name: 'Logs', icon: DocumentMagnifyingGlassIcon, adminOnly: true },
|
||||||
{ path: '/rate-limiter', name: 'Rate Limiter', icon: ShieldExclamationIcon, adminOnly: true },
|
{ path: '/rate-limiter', name: 'Rate Limiter', icon: ShieldExclamationIcon, adminOnly: true },
|
||||||
{ path: '/sessions', name: 'Sesiones', icon: ClockIcon, adminOnly: true },
|
{ path: '/sessions', name: 'Sesiones', icon: ClockIcon, adminOnly: true },
|
||||||
@@ -7,6 +7,8 @@ import ArticleDetail from './views/ArticleDetail.vue';
|
|||||||
import Favorites from './views/Favorites.vue';
|
import Favorites from './views/Favorites.vue';
|
||||||
import Workers from './views/Workers.vue';
|
import Workers from './views/Workers.vue';
|
||||||
import Users from './views/Users.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 Logs from './views/Logs.vue';
|
||||||
import RateLimiter from './views/RateLimiter.vue';
|
import RateLimiter from './views/RateLimiter.vue';
|
||||||
import Sessions from './views/Sessions.vue';
|
import Sessions from './views/Sessions.vue';
|
||||||
@@ -16,19 +18,21 @@ import authService from './services/auth';
|
|||||||
|
|
||||||
const routes = [
|
const routes = [
|
||||||
{ path: '/login', component: Login, name: 'login' },
|
{ path: '/login', component: Login, name: 'login' },
|
||||||
{ path: '/', component: Dashboard, meta: { requiresAuth: true } },
|
{ path: '/', component: Dashboard, meta: { requiresAuth: true } }, // Redirige a /dashboard
|
||||||
{ path: '/articles', component: Articles, meta: { requiresAuth: true } },
|
{ path: '/articles', component: Articles, meta: { requiresAuth: true } },
|
||||||
{ path: '/articles/:platform/:id', component: ArticleDetail, meta: { requiresAuth: true } },
|
{ path: '/articles/:platform/:id', component: ArticleDetail, meta: { requiresAuth: true } },
|
||||||
{ path: '/favorites', component: Favorites, meta: { requiresAuth: true } },
|
{ path: '/favorites', component: Favorites, meta: { requiresAuth: true } },
|
||||||
{ path: '/workers', component: Workers, 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: '/logs', component: Logs, meta: { requiresAuth: true } },
|
||||||
{ path: '/rate-limiter', component: RateLimiter, meta: { requiresAuth: true } },
|
{ path: '/rate-limiter', component: RateLimiter, meta: { requiresAuth: true } },
|
||||||
{ path: '/sessions', component: Sessions, meta: { requiresAuth: true } },
|
{ path: '/sessions', component: Sessions, meta: { requiresAuth: true } },
|
||||||
];
|
];
|
||||||
|
|
||||||
const router = createRouter({
|
const router = createRouter({
|
||||||
history: createWebHistory(),
|
history: createWebHistory('/dashboard'),
|
||||||
routes,
|
routes,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -47,6 +51,20 @@ router.beforeEach(async (to, from, next) => {
|
|||||||
return;
|
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
|
// Para todas las demás rutas, verificar autenticación
|
||||||
if (to.meta.requiresAuth) {
|
if (to.meta.requiresAuth) {
|
||||||
// Verificar si hay token almacenado
|
// Verificar si hay token almacenado
|
||||||
@@ -63,6 +81,16 @@ router.beforeEach(async (to, from, next) => {
|
|||||||
next('/login');
|
next('/login');
|
||||||
return;
|
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
|
// Continuar la navegación
|
||||||
@@ -171,5 +171,27 @@ export default {
|
|||||||
const response = await api.delete(`/admin/sessions/${token}`);
|
const response = await api.delete(`/admin/sessions/${token}`);
|
||||||
return response.data;
|
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;
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -259,7 +259,7 @@ async function handleLogin() {
|
|||||||
|
|
||||||
// Si llegamos aquí, el login fue exitoso
|
// Si llegamos aquí, el login fue exitoso
|
||||||
// Redirigir al dashboard
|
// Redirigir al dashboard
|
||||||
router.push('/');
|
router.push('/dashboard');
|
||||||
|
|
||||||
// Disparar evento para que App.vue se actualice
|
// Disparar evento para que App.vue se actualice
|
||||||
window.dispatchEvent(new CustomEvent('auth-login'));
|
window.dispatchEvent(new CustomEvent('auth-login'));
|
||||||
@@ -275,7 +275,7 @@ async function handleLogin() {
|
|||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
// Si ya está autenticado, redirigir al dashboard
|
// Si ya está autenticado, redirigir al dashboard
|
||||||
if (authService.hasCredentials()) {
|
if (authService.hasCredentials()) {
|
||||||
router.push('/');
|
router.push('/dashboard');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
314
web/dashboard/src/views/Settings.vue
Normal file
@@ -0,0 +1,314 @@
|
|||||||
|
<template>
|
||||||
|
<div class="space-y-6">
|
||||||
|
<!-- Page Header -->
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-2xl font-bold text-gray-900 dark:text-gray-100">Configuración</h1>
|
||||||
|
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
Gestiona tu configuración personal y preferencias
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Cambiar Contraseña -->
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h3 class="card-title">Cambiar Contraseña</h3>
|
||||||
|
<p class="card-subtitle">Actualiza tu contraseña de acceso</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form @submit.prevent="handleChangePassword" class="space-y-4">
|
||||||
|
<div v-if="passwordError" class="bg-red-100 dark:bg-red-900/30 border border-red-400 dark:border-red-700 text-red-700 dark:text-red-300 px-4 py-3 rounded">
|
||||||
|
{{ passwordError }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="passwordSuccess" class="bg-green-100 dark:bg-green-900/30 border border-green-400 dark:border-green-700 text-green-700 dark:text-green-300 px-4 py-3 rounded">
|
||||||
|
{{ passwordSuccess }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||||
|
Contraseña actual <span class="text-red-500">*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
v-model="passwordForm.currentPassword"
|
||||||
|
type="password"
|
||||||
|
class="input"
|
||||||
|
placeholder="••••••••"
|
||||||
|
required
|
||||||
|
autocomplete="current-password"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||||
|
Nueva contraseña <span class="text-red-500">*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
v-model="passwordForm.newPassword"
|
||||||
|
type="password"
|
||||||
|
class="input"
|
||||||
|
placeholder="••••••••"
|
||||||
|
required
|
||||||
|
minlength="6"
|
||||||
|
autocomplete="new-password"
|
||||||
|
/>
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||||
|
Mínimo 6 caracteres
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||||
|
Confirmar nueva contraseña <span class="text-red-500">*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
v-model="passwordForm.newPasswordConfirm"
|
||||||
|
type="password"
|
||||||
|
class="input"
|
||||||
|
placeholder="••••••••"
|
||||||
|
required
|
||||||
|
minlength="6"
|
||||||
|
autocomplete="new-password"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex justify-end gap-2 pt-4 border-t border-gray-200 dark:border-gray-700">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="btn btn-primary"
|
||||||
|
:disabled="loadingAction"
|
||||||
|
>
|
||||||
|
{{ loadingAction ? 'Cambiando...' : 'Cambiar Contraseña' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Configuración de Telegram -->
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h3 class="card-title">Configuración de Telegram</h3>
|
||||||
|
<p class="card-subtitle">Configura tu bot de Telegram y canal para recibir notificaciones</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form @submit.prevent="saveTelegramConfig" class="space-y-4">
|
||||||
|
<div v-if="telegramError" class="bg-red-100 dark:bg-red-900/30 border border-red-400 dark:border-red-700 text-red-700 dark:text-red-300 px-4 py-3 rounded">
|
||||||
|
{{ telegramError }}
|
||||||
|
</div>
|
||||||
|
<div v-if="telegramSuccess" class="bg-green-100 dark:bg-green-900/30 border border-green-400 dark:border-green-700 text-green-700 dark:text-green-300 px-4 py-3 rounded">
|
||||||
|
{{ telegramSuccess }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||||
|
Token del Bot <span class="text-red-500">*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
v-model="telegramForm.token"
|
||||||
|
type="password"
|
||||||
|
class="input"
|
||||||
|
placeholder="Ej: 123456789:ABCdefGHIjklMNOpqrsTUVwxyz"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||||
|
Obtén tu token desde @BotFather en Telegram
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||||
|
Canal o Grupo <span class="text-red-500">*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
v-model="telegramForm.channel"
|
||||||
|
type="text"
|
||||||
|
class="input"
|
||||||
|
placeholder="Ej: @micanal o -1001234567890"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||||
|
Usa @nombrecanal para canales públicos o el ID numérico para grupos/canales privados
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center">
|
||||||
|
<input
|
||||||
|
v-model="telegramForm.enable_polling"
|
||||||
|
type="checkbox"
|
||||||
|
id="enable_polling"
|
||||||
|
class="w-4 h-4 text-primary-600 rounded focus:ring-primary-500"
|
||||||
|
/>
|
||||||
|
<label for="enable_polling" class="ml-2 text-sm text-gray-700 dark:text-gray-300">
|
||||||
|
Habilitar polling del bot (para comandos /favs, /threads, etc.)
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex justify-end gap-2 pt-4 border-t border-gray-200 dark:border-gray-700">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="btn btn-primary"
|
||||||
|
:disabled="loadingAction"
|
||||||
|
>
|
||||||
|
{{ loadingAction ? 'Guardando...' : 'Guardar Configuración' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Información de la cuenta -->
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h3 class="card-title">Información de la Cuenta</h3>
|
||||||
|
<p class="card-subtitle">Datos de tu cuenta</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<p class="text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Nombre de usuario</p>
|
||||||
|
<p class="text-base text-gray-900 dark:text-gray-100 font-semibold">{{ currentUser }}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Rol</p>
|
||||||
|
<span
|
||||||
|
:class="isAdmin ? 'badge badge-warning' : 'badge badge-primary'"
|
||||||
|
>
|
||||||
|
{{ isAdmin ? 'Administrador' : 'Usuario' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, computed, onMounted } from 'vue';
|
||||||
|
import api from '../services/api';
|
||||||
|
import authService from '../services/auth';
|
||||||
|
|
||||||
|
const loadingAction = ref(false);
|
||||||
|
const passwordError = ref('');
|
||||||
|
const passwordSuccess = ref('');
|
||||||
|
const telegramError = ref('');
|
||||||
|
const telegramSuccess = ref('');
|
||||||
|
|
||||||
|
const passwordForm = ref({
|
||||||
|
currentPassword: '',
|
||||||
|
newPassword: '',
|
||||||
|
newPasswordConfirm: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
const telegramForm = ref({
|
||||||
|
token: '',
|
||||||
|
channel: '',
|
||||||
|
enable_polling: false
|
||||||
|
});
|
||||||
|
|
||||||
|
const currentUser = computed(() => {
|
||||||
|
return authService.getUsername() || '';
|
||||||
|
});
|
||||||
|
|
||||||
|
const isAdmin = computed(() => {
|
||||||
|
return authService.isAdmin();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function handleChangePassword() {
|
||||||
|
passwordError.value = '';
|
||||||
|
passwordSuccess.value = '';
|
||||||
|
loadingAction.value = true;
|
||||||
|
|
||||||
|
if (!passwordForm.value.currentPassword || !passwordForm.value.newPassword || !passwordForm.value.newPasswordConfirm) {
|
||||||
|
passwordError.value = 'Todos los campos son requeridos';
|
||||||
|
loadingAction.value = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (passwordForm.value.newPassword.length < 6) {
|
||||||
|
passwordError.value = 'La nueva contraseña debe tener al menos 6 caracteres';
|
||||||
|
loadingAction.value = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (passwordForm.value.newPassword !== passwordForm.value.newPasswordConfirm) {
|
||||||
|
passwordError.value = 'Las contraseñas no coinciden';
|
||||||
|
loadingAction.value = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await api.changePassword({
|
||||||
|
currentPassword: passwordForm.value.currentPassword,
|
||||||
|
newPassword: passwordForm.value.newPassword,
|
||||||
|
});
|
||||||
|
|
||||||
|
passwordSuccess.value = 'Contraseña actualizada correctamente. Por favor, inicia sesión nuevamente.';
|
||||||
|
|
||||||
|
// Invalidar la sesión actual - el usuario deberá hacer login nuevamente
|
||||||
|
setTimeout(async () => {
|
||||||
|
await authService.logout();
|
||||||
|
// Recargar página para forzar nuevo login
|
||||||
|
window.location.reload();
|
||||||
|
}, 2000);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error cambiando contraseña:', error);
|
||||||
|
if (error.response?.data?.error) {
|
||||||
|
passwordError.value = error.response.data.error;
|
||||||
|
} else {
|
||||||
|
passwordError.value = 'Error cambiando contraseña. Intenta de nuevo.';
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
loadingAction.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadTelegramConfig() {
|
||||||
|
try {
|
||||||
|
const config = await api.getTelegramConfig();
|
||||||
|
if (config) {
|
||||||
|
telegramForm.value = {
|
||||||
|
token: config.token || '',
|
||||||
|
channel: config.channel || '',
|
||||||
|
enable_polling: config.enable_polling || false
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error cargando configuración de Telegram:', error);
|
||||||
|
telegramError.value = 'Error cargando la configuración de Telegram';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveTelegramConfig() {
|
||||||
|
telegramError.value = '';
|
||||||
|
telegramSuccess.value = '';
|
||||||
|
|
||||||
|
if (!telegramForm.value.token || !telegramForm.value.channel) {
|
||||||
|
telegramError.value = 'Token y canal son requeridos';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
loadingAction.value = true;
|
||||||
|
try {
|
||||||
|
await api.setTelegramConfig(telegramForm.value);
|
||||||
|
telegramSuccess.value = 'Configuración de Telegram guardada correctamente';
|
||||||
|
setTimeout(() => {
|
||||||
|
telegramSuccess.value = '';
|
||||||
|
}, 3000);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error guardando configuración de Telegram:', error);
|
||||||
|
if (error.response?.data?.error) {
|
||||||
|
telegramError.value = error.response.data.error;
|
||||||
|
} else {
|
||||||
|
telegramError.value = 'Error al guardar la configuración de Telegram';
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
loadingAction.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
loadTelegramConfig();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
264
web/dashboard/src/views/Subscription.vue
Normal file
@@ -0,0 +1,264 @@
|
|||||||
|
<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>
|
||||||
|
|
||||||
|
<!-- 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">Información de los planes disponibles</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Mensaje informativo -->
|
||||||
|
<div class="mb-6 p-4 bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg">
|
||||||
|
<div class="flex items-start">
|
||||||
|
<svg class="w-5 h-5 text-yellow-600 dark:text-yellow-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-yellow-800 dark:text-yellow-300 mb-1">Cambio de plan temporalmente deshabilitado</h4>
|
||||||
|
<p class="text-sm text-yellow-700 dark:text-yellow-400">
|
||||||
|
El cambio de plan no está disponible por el momento. Si necesitas cambiar tu plan, contacta con el administrador.
|
||||||
|
</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 opacity-75"
|
||||||
|
:class="
|
||||||
|
plan.id === subscription?.subscription?.planId
|
||||||
|
? 'border-primary-500 dark:border-primary-400 bg-primary-50 dark:bg-primary-900/20'
|
||||||
|
: 'border-gray-200 dark:border-gray-800'
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ul class="space-y-2 mb-6">
|
||||||
|
<li v-for="feature in plan.features.slice(0, 3)" :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"
|
||||||
|
class="w-full btn btn-primary opacity-50 cursor-not-allowed"
|
||||||
|
disabled
|
||||||
|
title="Cambio de plan temporalmente deshabilitado"
|
||||||
|
>
|
||||||
|
No disponible
|
||||||
|
</button>
|
||||||
|
<div v-else class="w-full text-center py-2 px-4 bg-primary-100 dark:bg-primary-900/50 text-primary-700 dark:text-primary-400 rounded-lg font-semibold">
|
||||||
|
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);
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
if (!confirm(`¿Estás seguro de que quieres cambiar a el plan ${plans.value.find(p => p.id === planId)?.name || planId}?`)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
upgrading.value = true;
|
||||||
|
await api.updateSubscription({ planId });
|
||||||
|
// Plan actualizado correctamente
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
loadSubscription();
|
||||||
|
loadPlans();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
@@ -3,13 +3,6 @@
|
|||||||
<div class="flex flex-col sm:flex-row sm:justify-between sm:items-center gap-3 sm:gap-4 mb-4 sm:mb-6">
|
<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>
|
<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">
|
<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
|
<button
|
||||||
v-if="isAdmin"
|
v-if="isAdmin"
|
||||||
@click="showAddModal = true"
|
@click="showAddModal = true"
|
||||||
@@ -20,7 +13,17 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="loading" class="text-center py-12">
|
<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>
|
<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>
|
<p class="mt-2 text-gray-600 dark:text-gray-400">Cargando usuarios...</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -51,7 +54,7 @@
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-2 text-sm text-gray-600 dark:text-gray-400">
|
<div class="grid grid-cols-1 sm:grid-cols-2 gap-2 text-sm text-gray-600 dark:text-gray-400 mb-3">
|
||||||
<div v-if="user.createdAt">
|
<div v-if="user.createdAt">
|
||||||
<span class="font-medium">Creado:</span>
|
<span class="font-medium">Creado:</span>
|
||||||
<span class="ml-2">{{ formatDate(user.createdAt) }}</span>
|
<span class="ml-2">{{ formatDate(user.createdAt) }}</span>
|
||||||
@@ -65,24 +68,37 @@
|
|||||||
<span class="ml-2">{{ formatDate(user.updatedAt) }}</span>
|
<span class="ml-2">{{ formatDate(user.updatedAt) }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Información de suscripción -->
|
||||||
|
<div v-if="userSubscriptions[user.username]" class="mt-3 p-3 bg-gradient-to-r 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-800">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<span class="text-xs font-semibold text-gray-700 dark:text-gray-300">Plan:</span>
|
||||||
|
<span class="ml-2 text-sm font-bold text-primary-700 dark:text-primary-400">
|
||||||
|
{{ userSubscriptions[user.username].plan?.name || 'Gratis' }}
|
||||||
|
</span>
|
||||||
|
</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="mt-2 text-xs text-gray-600 dark:text-gray-400">
|
||||||
|
<span>Uso: {{ userSubscriptions[user.username].usage.workers }} / {{ userSubscriptions[user.username].usage.maxWorkers === 'Ilimitado' ? '∞' : userSubscriptions[user.username].usage.maxWorkers }} búsquedas</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2 flex-wrap">
|
||||||
<button
|
<button
|
||||||
v-if="user.username === currentUser"
|
v-if="isAdmin"
|
||||||
@click="showTelegramModal = true"
|
@click="openSubscriptionModal(user.username)"
|
||||||
class="btn btn-secondary text-xs sm:text-sm"
|
class="btn btn-primary text-xs sm:text-sm"
|
||||||
title="Configurar Telegram"
|
title="Gestionar suscripción"
|
||||||
>
|
>
|
||||||
📱 Telegram
|
💳 Plan
|
||||||
</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>
|
||||||
<button
|
<button
|
||||||
v-if="user.username !== currentUser && isAdmin"
|
v-if="user.username !== currentUser && isAdmin"
|
||||||
@@ -199,17 +215,20 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Modal para cambiar contraseña -->
|
|
||||||
|
<!-- Modal para gestionar suscripción (solo admin) -->
|
||||||
<div
|
<div
|
||||||
v-if="showChangePasswordModal"
|
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"
|
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"
|
@click.self="closeSubscriptionModal"
|
||||||
>
|
>
|
||||||
<div class="card max-w-md w-full">
|
<div class="card max-w-2xl w-full max-h-[90vh] overflow-y-auto">
|
||||||
<div class="flex items-center justify-between mb-4">
|
<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>
|
<h2 class="text-xl font-bold text-gray-900 dark:text-gray-100">
|
||||||
|
Gestionar Suscripción: {{ selectedUserForSubscription }}
|
||||||
|
</h2>
|
||||||
<button
|
<button
|
||||||
@click="closeChangePasswordModal"
|
@click="closeSubscriptionModal"
|
||||||
class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
|
class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
|
||||||
title="Cerrar"
|
title="Cerrar"
|
||||||
>
|
>
|
||||||
@@ -217,155 +236,121 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form @submit.prevent="handleChangePassword" class="space-y-4">
|
<div v-if="loadingSubscription" class="text-center py-8">
|
||||||
<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">
|
<div class="inline-block animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600"></div>
|
||||||
{{ passwordError }}
|
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">Cargando información...</p>
|
||||||
</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>
|
</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.
|
<div v-else class="space-y-6">
|
||||||
</p>
|
<!-- Información actual -->
|
||||||
<form @submit.prevent="saveTelegramConfig" class="space-y-4">
|
<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">
|
||||||
<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">
|
<h3 class="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-3">Plan Actual</h3>
|
||||||
{{ telegramError }}
|
<div class="grid grid-cols-2 gap-4">
|
||||||
</div>
|
<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">
|
<p class="text-xs text-gray-600 dark:text-gray-400">Plan</p>
|
||||||
{{ telegramSuccess }}
|
<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>
|
</div>
|
||||||
|
|
||||||
|
<!-- Cambiar plan -->
|
||||||
<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>
|
<h3 class="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-3">Cambiar Plan</h3>
|
||||||
<input
|
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||||
v-model="telegramForm.token"
|
<button
|
||||||
type="password"
|
v-for="plan in availablePlans"
|
||||||
class="input"
|
:key="plan.id"
|
||||||
placeholder="Ej: 123456789:ABCdefGHIjklMNOpqrsTUVwxyz"
|
@click="subscriptionForm.planId = plan.id"
|
||||||
required
|
class="p-4 rounded-lg border-2 text-left transition-all"
|
||||||
/>
|
:class="
|
||||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
subscriptionForm.planId === plan.id
|
||||||
Obtén tu token desde @BotFather en Telegram
|
? 'border-primary-500 dark:border-primary-400 bg-primary-50 dark:bg-primary-900/20'
|
||||||
</p>
|
: '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>
|
</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>
|
<!-- Formulario de actualización -->
|
||||||
<input
|
<form @submit.prevent="handleUpdateUserSubscription" class="space-y-4">
|
||||||
v-model="telegramForm.channel"
|
<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">
|
||||||
type="text"
|
{{ subscriptionError }}
|
||||||
class="input"
|
</div>
|
||||||
placeholder="Ej: @micanal o -1001234567890"
|
|
||||||
required
|
<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 }}
|
||||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
</div>
|
||||||
Usa @nombrecanal para canales públicos o el ID numérico para grupos/canales privados
|
|
||||||
</p>
|
<div>
|
||||||
</div>
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||||
<div class="flex items-center">
|
Estado de la suscripción
|
||||||
<input
|
</label>
|
||||||
v-model="telegramForm.enable_polling"
|
<select
|
||||||
type="checkbox"
|
v-model="subscriptionForm.status"
|
||||||
id="enable_polling"
|
class="input"
|
||||||
class="w-4 h-4 text-primary-600 rounded focus:ring-primary-500"
|
>
|
||||||
/>
|
<option value="active">Activo</option>
|
||||||
<label for="enable_polling" class="ml-2 text-sm text-gray-700 dark:text-gray-300">
|
<option value="inactive">Inactivo</option>
|
||||||
Habilitar polling del bot (para comandos /favs, /threads, etc.)
|
<option value="cancelled">Cancelado</option>
|
||||||
</label>
|
</select>
|
||||||
</div>
|
</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">
|
<div class="flex items-center">
|
||||||
Cancelar
|
<input
|
||||||
</button>
|
v-model="subscriptionForm.cancelAtPeriodEnd"
|
||||||
<button type="submit" class="btn btn-primary text-sm sm:text-base" :disabled="loadingAction">
|
type="checkbox"
|
||||||
{{ loadingAction ? 'Guardando...' : 'Guardar' }}
|
id="cancelAtPeriodEnd"
|
||||||
</button>
|
class="w-4 h-4 text-primary-600 rounded focus:ring-primary-500"
|
||||||
</div>
|
/>
|
||||||
</form>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -425,14 +410,15 @@ const users = ref([]);
|
|||||||
const loading = ref(true);
|
const loading = ref(true);
|
||||||
const loadingAction = ref(false);
|
const loadingAction = ref(false);
|
||||||
const showAddModal = ref(false);
|
const showAddModal = ref(false);
|
||||||
const showChangePasswordModal = ref(false);
|
const showSubscriptionModal = ref(false);
|
||||||
const showTelegramModal = ref(false);
|
|
||||||
const userToDelete = ref(null);
|
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 addError = ref('');
|
||||||
const passwordError = ref('');
|
|
||||||
const passwordSuccess = ref('');
|
|
||||||
const telegramError = ref('');
|
|
||||||
const telegramSuccess = ref('');
|
|
||||||
|
|
||||||
const userForm = ref({
|
const userForm = ref({
|
||||||
username: '',
|
username: '',
|
||||||
@@ -440,17 +426,13 @@ const userForm = ref({
|
|||||||
passwordConfirm: '',
|
passwordConfirm: '',
|
||||||
});
|
});
|
||||||
|
|
||||||
const passwordForm = ref({
|
const subscriptionForm = ref({
|
||||||
currentPassword: '',
|
planId: 'free',
|
||||||
newPassword: '',
|
status: 'active',
|
||||||
newPasswordConfirm: '',
|
cancelAtPeriodEnd: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
const telegramForm = ref({
|
const selectedUserSubscription = ref(null);
|
||||||
token: '',
|
|
||||||
channel: '',
|
|
||||||
enable_polling: false
|
|
||||||
});
|
|
||||||
|
|
||||||
const isAuthenticated = computed(() => authService.hasCredentials());
|
const isAuthenticated = computed(() => authService.hasCredentials());
|
||||||
const currentUser = computed(() => {
|
const currentUser = computed(() => {
|
||||||
@@ -481,6 +463,11 @@ async function loadUsers() {
|
|||||||
try {
|
try {
|
||||||
const data = await api.getUsers();
|
const data = await api.getUsers();
|
||||||
users.value = data.users || [];
|
users.value = data.users || [];
|
||||||
|
|
||||||
|
// Cargar información de suscripción para todos los usuarios (solo si es admin)
|
||||||
|
if (isAdmin.value) {
|
||||||
|
await loadAllUserSubscriptions();
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error cargando usuarios:', error);
|
console.error('Error cargando usuarios:', error);
|
||||||
// El modal de login se manejará automáticamente desde App.vue
|
// El modal de login se manejará automáticamente desde App.vue
|
||||||
@@ -489,6 +476,132 @@ async function loadUsers() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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();
|
||||||
|
userSubscriptions.value[user.username] = data;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error cargando suscripción de ${user.username}:`, error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
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() {
|
async function handleCreateUser() {
|
||||||
addError.value = '';
|
addError.value = '';
|
||||||
loadingAction.value = true;
|
loadingAction.value = true;
|
||||||
@@ -537,57 +650,6 @@ async function handleCreateUser() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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() {
|
async function handleDeleteUser() {
|
||||||
if (!userToDelete.value) return;
|
if (!userToDelete.value) return;
|
||||||
@@ -619,81 +681,12 @@ function closeAddModal() {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
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() {
|
function handleAuthLogout() {
|
||||||
// Cuando el usuario se desconecta globalmente, limpiar datos
|
// Cuando el usuario se desconecta globalmente, limpiar datos
|
||||||
users.value = [];
|
users.value = [];
|
||||||
showAddModal.value = false;
|
showAddModal.value = false;
|
||||||
showChangePasswordModal.value = false;
|
|
||||||
userToDelete.value = null;
|
userToDelete.value = null;
|
||||||
addError.value = '';
|
addError.value = '';
|
||||||
passwordError.value = '';
|
|
||||||
passwordSuccess.value = '';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
@@ -705,13 +698,6 @@ onMounted(() => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Cargar configuración de Telegram cuando se abre el modal
|
|
||||||
watch(showTelegramModal, (newVal) => {
|
|
||||||
if (newVal) {
|
|
||||||
loadTelegramConfig();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
window.removeEventListener('auth-logout', handleAuthLogout);
|
window.removeEventListener('auth-logout', handleAuthLogout);
|
||||||
window.removeEventListener('auth-login', loadUsers);
|
window.removeEventListener('auth-login', loadUsers);
|
||||||
@@ -4,6 +4,7 @@ import { fileURLToPath, URL } from 'url';
|
|||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [vue()],
|
plugins: [vue()],
|
||||||
|
base: '/dashboard/',
|
||||||
resolve: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
'@': fileURLToPath(new URL('./src', import.meta.url)),
|
'@': fileURLToPath(new URL('./src', import.meta.url)),
|
||||||
23
web/landing/.gitignore
vendored
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
# build output
|
||||||
|
dist/
|
||||||
|
.output/
|
||||||
|
|
||||||
|
# generated types
|
||||||
|
.astro/
|
||||||
|
|
||||||
|
# dependencies
|
||||||
|
node_modules/
|
||||||
|
|
||||||
|
# logs
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
|
||||||
|
# environment variables
|
||||||
|
.env
|
||||||
|
.env.production
|
||||||
|
|
||||||
|
# macOS-specific files
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
27
web/landing/Dockerfile
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
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 Astro
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# Stage de producción - servir con nginx
|
||||||
|
FROM nginx:alpine
|
||||||
|
|
||||||
|
# Copiar archivos construidos
|
||||||
|
COPY --from=builder /app/dist /usr/share/nginx/html/landing
|
||||||
|
|
||||||
|
# Exponer puerto
|
||||||
|
EXPOSE 80
|
||||||
|
|
||||||
|
CMD ["nginx", "-g", "daemon off;"]
|
||||||
|
|
||||||
77
web/landing/README.md
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
# Wallabicher Landing Page
|
||||||
|
|
||||||
|
Landing page moderna y profesional para Wallabicher construida con Astro. Diseño minimalista con modo dark automático basado en las preferencias del sistema.
|
||||||
|
|
||||||
|
## ✨ Características
|
||||||
|
|
||||||
|
- 🎨 **Diseño minimalista y profesional** - Interfaz limpia y moderna
|
||||||
|
- 🌙 **Modo dark automático** - Se adapta automáticamente a las preferencias del sistema
|
||||||
|
- 🎭 **Animaciones suaves** - Transiciones y efectos visuales elegantes
|
||||||
|
- 📱 **Totalmente responsive** - Optimizado para todos los dispositivos
|
||||||
|
- 🎯 **Colores del logo** - Paleta de colores teal/cyan basada en el logo
|
||||||
|
- ⚡ **Rendimiento óptimo** - Construido con Astro para máxima velocidad
|
||||||
|
|
||||||
|
## 🚀 Inicio Rápido
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Instalar dependencias
|
||||||
|
npm install
|
||||||
|
|
||||||
|
# Iniciar servidor de desarrollo
|
||||||
|
npm run dev
|
||||||
|
|
||||||
|
# Construir para producción
|
||||||
|
npm run build
|
||||||
|
|
||||||
|
# Previsualizar build de producción
|
||||||
|
npm run preview
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📁 Estructura
|
||||||
|
|
||||||
|
```
|
||||||
|
landing/
|
||||||
|
├── public/
|
||||||
|
│ ├── favicon.svg
|
||||||
|
│ └── logo.jpg # Logo de Wallabicher
|
||||||
|
├── src/
|
||||||
|
│ ├── components/
|
||||||
|
│ │ ├── CTA.astro # Sección de llamada a la acción
|
||||||
|
│ │ ├── Features.astro # Características principales
|
||||||
|
│ │ ├── Hero.astro # Sección hero con logo
|
||||||
|
│ │ ├── HowItWorks.astro # Cómo funciona
|
||||||
|
│ │ └── Platforms.astro # Plataformas soportadas
|
||||||
|
│ ├── layouts/
|
||||||
|
│ │ └── Layout.astro # Layout base con modo dark
|
||||||
|
│ └── pages/
|
||||||
|
│ └── index.astro # Página principal
|
||||||
|
├── astro.config.mjs
|
||||||
|
├── package.json
|
||||||
|
└── tailwind.config.mjs # Configuración con colores teal/cyan
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🎨 Tecnologías
|
||||||
|
|
||||||
|
- **Astro** - Framework web moderno y rápido
|
||||||
|
- **Tailwind CSS** - Framework CSS utility-first
|
||||||
|
- **TypeScript** - Tipado estático
|
||||||
|
- **Inter Font** - Tipografía profesional
|
||||||
|
|
||||||
|
## 🎨 Paleta de Colores
|
||||||
|
|
||||||
|
La landing page usa una paleta de colores basada en el logo:
|
||||||
|
- **Primary (Teal/Cyan)**: `#06b6d4` - Color principal
|
||||||
|
- **Teal**: Variaciones de teal para acentos
|
||||||
|
- **Gray**: Escala de grises para texto y fondos
|
||||||
|
- **Modo Dark**: Adaptación automática con colores oscuros
|
||||||
|
|
||||||
|
## 📝 Personalización
|
||||||
|
|
||||||
|
- **Colores**: Modifica `tailwind.config.mjs` para cambiar la paleta
|
||||||
|
- **Componentes**: Edita los archivos en `src/components/`
|
||||||
|
- **Contenido**: Actualiza el texto en cada componente según necesites
|
||||||
|
|
||||||
|
## 🌙 Modo Dark
|
||||||
|
|
||||||
|
El modo dark se detecta automáticamente usando `prefers-color-scheme`. No requiere JavaScript adicional y funciona de forma nativa.
|
||||||
|
|
||||||
9
web/landing/astro.config.mjs
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { defineConfig } from 'astro/config';
|
||||||
|
import tailwind from '@astrojs/tailwind';
|
||||||
|
|
||||||
|
// https://astro.build/config
|
||||||
|
export default defineConfig({
|
||||||
|
integrations: [tailwind()],
|
||||||
|
output: 'static',
|
||||||
|
});
|
||||||
|
|
||||||
6119
web/landing/package-lock.json
generated
Normal file
18
web/landing/package.json
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"name": "wallabicher-landing",
|
||||||
|
"type": "module",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "astro dev",
|
||||||
|
"start": "astro dev",
|
||||||
|
"build": "astro build",
|
||||||
|
"preview": "astro preview",
|
||||||
|
"astro": "astro"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@astrojs/tailwind": "^5.1.0",
|
||||||
|
"astro": "^4.15.0",
|
||||||
|
"tailwindcss": "^3.4.1"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
5
web/landing/public/favicon.svg
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
|
||||||
|
<circle cx="50" cy="50" r="45" fill="#0ea5e9"/>
|
||||||
|
<text x="50" y="70" font-size="60" text-anchor="middle" fill="white">🛎️</text>
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
After Width: | Height: | Size: 207 B |
BIN
web/landing/public/logo.jpg
Normal file
|
After Width: | Height: | Size: 39 KiB |
91
web/landing/src/components/CTA.astro
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
---
|
||||||
|
---
|
||||||
|
|
||||||
|
<section id="instalacion" class="py-24 sm:py-32 bg-gradient-to-br from-primary-600 via-teal-600 to-cyan-600 dark:from-primary-800 dark:via-teal-800 dark:to-cyan-800 text-white relative overflow-hidden">
|
||||||
|
<!-- Background decoration -->
|
||||||
|
<div class="absolute inset-0">
|
||||||
|
<div class="absolute top-0 left-0 w-96 h-96 bg-white/10 rounded-full blur-3xl"></div>
|
||||||
|
<div class="absolute bottom-0 right-0 w-96 h-96 bg-white/10 rounded-full blur-3xl"></div>
|
||||||
|
<div class="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[600px] h-[600px] bg-white/5 rounded-full blur-3xl"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="relative z-10 max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 text-center animate-fade-in">
|
||||||
|
<h2 class="text-4xl sm:text-5xl lg:text-6xl font-bold mb-6">
|
||||||
|
¿Listo para empezar?
|
||||||
|
</h2>
|
||||||
|
<p class="text-xl text-primary-100 dark:text-primary-200 mb-12 max-w-2xl mx-auto">
|
||||||
|
Instala Wallabicher en minutos y comienza a recibir notificaciones de los artículos que te interesan.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<!-- Installation code block -->
|
||||||
|
<div class="mb-12 animate-slide-up" style="animation-delay: 0.2s;">
|
||||||
|
<div class="bg-gray-900/90 dark:bg-gray-950/90 backdrop-blur-sm rounded-2xl p-6 sm:p-8 border border-white/10 shadow-2xl">
|
||||||
|
<div class="flex items-center justify-between mb-4">
|
||||||
|
<div class="flex items-center space-x-2">
|
||||||
|
<div class="w-3 h-3 rounded-full bg-red-500"></div>
|
||||||
|
<div class="w-3 h-3 rounded-full bg-yellow-500"></div>
|
||||||
|
<div class="w-3 h-3 rounded-full bg-green-500"></div>
|
||||||
|
</div>
|
||||||
|
<span class="text-sm text-gray-400 font-mono">Terminal</span>
|
||||||
|
</div>
|
||||||
|
<pre class="text-left overflow-x-auto"><code class="text-sm sm:text-base text-gray-100 font-mono"># 1. Instala las dependencias
|
||||||
|
pip3 install -r requirements.txt
|
||||||
|
|
||||||
|
# 2. Configura el proyecto
|
||||||
|
python setup_config.py
|
||||||
|
|
||||||
|
# 3. Edita config.yaml con tus credenciales de Telegram
|
||||||
|
|
||||||
|
# 4. Personaliza workers.json con tus búsquedas
|
||||||
|
|
||||||
|
# 5. Ejecuta Wallabicher
|
||||||
|
python3 wallabicher.py</code></pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- CTA Buttons -->
|
||||||
|
<div class="flex flex-col sm:flex-row gap-4 justify-center items-center animate-slide-up" style="animation-delay: 0.3s;">
|
||||||
|
<a
|
||||||
|
href="https://github.com"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
class="group inline-flex items-center justify-center px-8 py-4 text-lg font-semibold text-primary-900 bg-white rounded-xl hover:bg-primary-50 transition-all duration-300 shadow-xl hover:shadow-2xl transform hover:-translate-y-1"
|
||||||
|
>
|
||||||
|
<span>Ver Documentación Completa</span>
|
||||||
|
<svg class="w-5 h-5 ml-2 transform group-hover:translate-x-1 transition-transform" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"></path>
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href="https://github.com"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
class="inline-flex items-center justify-center px-8 py-4 text-lg font-semibold text-white bg-white/10 backdrop-blur-sm rounded-xl hover:bg-white/20 transition-all duration-300 border-2 border-white/30 hover:border-white/50 shadow-xl hover:shadow-2xl transform hover:-translate-y-1"
|
||||||
|
>
|
||||||
|
<svg class="w-5 h-5 mr-2" fill="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/>
|
||||||
|
</svg>
|
||||||
|
Descargar en GitHub
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<footer class="bg-gray-900 dark:bg-gray-950 text-gray-300 dark:text-gray-600 py-12 border-t border-gray-800 dark:border-gray-800">
|
||||||
|
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
|
<div class="text-center">
|
||||||
|
<div class="flex items-center justify-center mb-6">
|
||||||
|
<div class="w-12 h-12 bg-white/10 rounded-xl p-2 mr-3">
|
||||||
|
<img src="/logo.jpg" alt="Wallabicher" class="w-full h-full object-contain" />
|
||||||
|
</div>
|
||||||
|
<h3 class="text-xl font-bold text-white dark:text-gray-300">Wallabicher</h3>
|
||||||
|
</div>
|
||||||
|
<p class="text-base mb-4 text-gray-400 dark:text-gray-500">
|
||||||
|
Hecho con <span class="text-red-500">❤️</span> para ayudarte a encontrar las mejores ofertas
|
||||||
|
</p>
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-600">
|
||||||
|
© 2024 Wallabicher. Todos los derechos reservados.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
98
web/landing/src/components/Features.astro
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
---
|
||||||
|
const features = [
|
||||||
|
{
|
||||||
|
icon: '🔍',
|
||||||
|
title: 'Búsquedas Automatizadas',
|
||||||
|
description: 'Configura tus criterios de búsqueda una vez y deja que Wallabicher monitoree las plataformas 24/7 sin interrupciones.',
|
||||||
|
color: 'from-primary-500 to-primary-600',
|
||||||
|
delay: '0.1s',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: '⚡',
|
||||||
|
title: 'Notificaciones Instantáneas',
|
||||||
|
description: 'Recibe alertas en tiempo real en tu canal o chat de Telegram cuando aparezcan artículos que coincidan con tus filtros.',
|
||||||
|
color: 'from-teal-500 to-teal-600',
|
||||||
|
delay: '0.2s',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: '🎯',
|
||||||
|
title: 'Filtros Avanzados',
|
||||||
|
description: 'Filtra por precio, ubicación, condición, palabras clave y mucho más. Control total sobre lo que quieres ver.',
|
||||||
|
color: 'from-cyan-500 to-cyan-600',
|
||||||
|
delay: '0.3s',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: '📱',
|
||||||
|
title: 'Integración con Telegram',
|
||||||
|
description: 'Recibe notificaciones con imágenes, descripciones y enlaces directos. Incluso organiza por temas en grupos.',
|
||||||
|
color: 'from-primary-500 to-teal-500',
|
||||||
|
delay: '0.4s',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: '⭐',
|
||||||
|
title: 'Sistema de Favoritos',
|
||||||
|
description: 'Guarda tus artículos favoritos con un solo clic y accede a ellos cuando quieras con el comando /favs.',
|
||||||
|
color: 'from-teal-500 to-cyan-500',
|
||||||
|
delay: '0.5s',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: '🌐',
|
||||||
|
title: 'Multi-Plataforma',
|
||||||
|
description: 'Soporta múltiples marketplaces: Wallapop, Vinted y más. Arquitectura extensible para añadir nuevas plataformas fácilmente.',
|
||||||
|
color: 'from-cyan-500 to-primary-500',
|
||||||
|
delay: '0.6s',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
---
|
||||||
|
|
||||||
|
<section id="caracteristicas" class="py-24 sm:py-32 bg-white dark:bg-gray-950 relative overflow-hidden">
|
||||||
|
<!-- Background decoration -->
|
||||||
|
<div class="absolute top-0 left-0 w-full h-full">
|
||||||
|
<div class="absolute top-0 right-0 w-96 h-96 bg-primary-100/30 dark:bg-primary-900/20 rounded-full blur-3xl"></div>
|
||||||
|
<div class="absolute bottom-0 left-0 w-96 h-96 bg-teal-100/30 dark:bg-teal-900/20 rounded-full blur-3xl"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="relative z-10 max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
|
<div class="text-center mb-16 animate-fade-in">
|
||||||
|
<h2 class="text-4xl sm:text-5xl lg:text-6xl font-bold text-gray-900 dark:text-gray-50 mb-4">
|
||||||
|
Características
|
||||||
|
<span class="block text-transparent bg-clip-text bg-gradient-to-r from-primary-600 via-teal-600 to-cyan-600 dark:from-primary-400 dark:via-teal-400 dark:to-cyan-400">
|
||||||
|
Principales
|
||||||
|
</span>
|
||||||
|
</h2>
|
||||||
|
<p class="text-xl text-gray-600 dark:text-gray-400 max-w-2xl mx-auto">
|
||||||
|
Todo lo que necesitas para no perderte ningún artículo interesante
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
|
||||||
|
{features.map((feature, index) => (
|
||||||
|
<div
|
||||||
|
class="group relative p-8 rounded-2xl bg-gradient-to-br from-white to-gray-50 dark:from-gray-900 dark:to-gray-800 border border-gray-200 dark:border-gray-800 hover:border-primary-300 dark:hover:border-primary-700 transition-all duration-300 transform hover:-translate-y-2 hover:shadow-2xl animate-slide-up"
|
||||||
|
style={`animation-delay: ${feature.delay}`}
|
||||||
|
>
|
||||||
|
<!-- Gradient overlay on hover -->
|
||||||
|
<div class={`absolute inset-0 bg-gradient-to-br ${feature.color} opacity-0 group-hover:opacity-5 rounded-2xl transition-opacity duration-300`}></div>
|
||||||
|
|
||||||
|
<div class="relative z-10">
|
||||||
|
<!-- Icon -->
|
||||||
|
<div class="inline-flex items-center justify-center w-16 h-16 rounded-xl bg-gradient-to-br from-primary-500/10 to-teal-500/10 dark:from-primary-500/20 dark:to-teal-500/20 mb-6 group-hover:scale-110 transition-transform duration-300">
|
||||||
|
<span class="text-3xl">{feature.icon}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Content -->
|
||||||
|
<h3 class="text-xl font-bold text-gray-900 dark:text-gray-50 mb-3 group-hover:text-primary-600 dark:group-hover:text-primary-400 transition-colors">
|
||||||
|
{feature.title}
|
||||||
|
</h3>
|
||||||
|
<p class="text-gray-600 dark:text-gray-400 leading-relaxed">
|
||||||
|
{feature.description}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Decorative corner -->
|
||||||
|
<div class={`absolute top-0 right-0 w-20 h-20 bg-gradient-to-br ${feature.color} opacity-0 group-hover:opacity-10 rounded-bl-full transition-opacity duration-300`}></div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
84
web/landing/src/components/Hero.astro
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
---
|
||||||
|
---
|
||||||
|
|
||||||
|
<section class="relative min-h-screen flex items-center justify-center overflow-hidden bg-gradient-to-br from-primary-50 via-teal-50 to-cyan-50 dark:from-gray-950 dark:via-gray-900 dark:to-primary-950">
|
||||||
|
<!-- Background decorative elements -->
|
||||||
|
<div class="absolute inset-0 overflow-hidden">
|
||||||
|
<div class="absolute top-0 -left-4 w-96 h-96 bg-primary-300/30 dark:bg-primary-800/20 rounded-full blur-3xl animate-float"></div>
|
||||||
|
<div class="absolute bottom-0 -right-4 w-96 h-96 bg-teal-300/30 dark:bg-teal-800/20 rounded-full blur-3xl animate-float" style="animation-delay: 2s;"></div>
|
||||||
|
<div class="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[600px] h-[600px] bg-primary-200/20 dark:bg-primary-900/20 rounded-full blur-3xl animate-float" style="animation-delay: 4s;"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Grid pattern overlay -->
|
||||||
|
<div class="absolute inset-0 bg-grid-pattern opacity-[0.03] dark:opacity-[0.05]"></div>
|
||||||
|
|
||||||
|
<div class="relative z-10 max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-24 sm:py-32">
|
||||||
|
<div class="text-center animate-fade-in">
|
||||||
|
<!-- Logo -->
|
||||||
|
<div class="flex justify-center mb-8 animate-slide-up">
|
||||||
|
<div class="relative">
|
||||||
|
<div class="absolute inset-0 bg-primary-500/20 dark:bg-primary-600/30 rounded-2xl blur-xl"></div>
|
||||||
|
<div class="relative w-24 h-24 sm:w-32 sm:h-32 bg-white/80 dark:bg-gray-900/80 backdrop-blur-sm rounded-2xl p-3 shadow-2xl ring-2 ring-primary-200/50 dark:ring-primary-800/50">
|
||||||
|
<img
|
||||||
|
src="/logo.jpg"
|
||||||
|
alt="Wallabicher Logo"
|
||||||
|
class="w-full h-full object-contain rounded-lg"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Title -->
|
||||||
|
<h1 class="text-5xl sm:text-6xl lg:text-7xl xl:text-8xl font-bold tracking-tight mb-6 animate-slide-up" style="animation-delay: 0.1s;">
|
||||||
|
<span class="bg-gradient-to-r from-primary-600 via-teal-600 to-cyan-600 dark:from-primary-400 dark:via-teal-400 dark:to-cyan-400 bg-clip-text text-transparent">
|
||||||
|
Wallabicher
|
||||||
|
</span>
|
||||||
|
<span class="block text-4xl sm:text-5xl lg:text-6xl mt-2">🛎️</span>
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<!-- Subtitle -->
|
||||||
|
<p class="text-xl sm:text-2xl lg:text-3xl text-gray-700 dark:text-gray-300 max-w-3xl mx-auto mb-4 font-medium animate-slide-up" style="animation-delay: 0.2s;">
|
||||||
|
Automatiza tus búsquedas en marketplaces
|
||||||
|
</p>
|
||||||
|
<p class="text-lg sm:text-xl text-gray-600 dark:text-gray-400 max-w-2xl mx-auto mb-12 animate-slide-up" style="animation-delay: 0.3s;">
|
||||||
|
Recibe notificaciones instantáneas en Telegram cuando aparezcan artículos que te interesan
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<!-- CTA Buttons -->
|
||||||
|
<div class="flex flex-col sm:flex-row gap-4 justify-center items-center animate-slide-up" style="animation-delay: 0.4s;">
|
||||||
|
<a
|
||||||
|
href="#precios"
|
||||||
|
class="group relative inline-flex items-center justify-center px-8 py-4 text-lg font-semibold text-white bg-gradient-to-r from-primary-600 to-teal-600 hover:from-primary-700 hover:to-teal-700 rounded-xl shadow-lg hover:shadow-xl transition-all duration-300 transform hover:-translate-y-1"
|
||||||
|
>
|
||||||
|
<span class="relative z-10">Ver Planes</span>
|
||||||
|
<div class="absolute inset-0 bg-gradient-to-r from-primary-700 to-teal-700 rounded-xl opacity-0 group-hover:opacity-100 transition-opacity duration-300"></div>
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href="#instalacion"
|
||||||
|
class="inline-flex items-center justify-center px-8 py-4 text-lg font-semibold text-gray-900 dark:text-gray-100 bg-white/80 dark:bg-gray-800/80 backdrop-blur-sm rounded-xl hover:bg-white dark:hover:bg-gray-800 transition-all duration-300 border-2 border-gray-200 dark:border-gray-700 shadow-lg hover:shadow-xl transform hover:-translate-y-1"
|
||||||
|
>
|
||||||
|
Empezar gratis
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Scroll indicator -->
|
||||||
|
<div class="absolute bottom-8 left-1/2 -translate-x-1/2 animate-bounce">
|
||||||
|
<svg class="w-6 h-6 text-gray-400 dark:text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 14l-7 7m0 0l-7-7m7 7V3"></path>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Gradient fade at bottom -->
|
||||||
|
<div class="absolute bottom-0 left-0 right-0 h-32 bg-gradient-to-t from-white dark:from-gray-950 to-transparent"></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.bg-grid-pattern {
|
||||||
|
background-image:
|
||||||
|
linear-gradient(to right, currentColor 1px, transparent 1px),
|
||||||
|
linear-gradient(to bottom, currentColor 1px, transparent 1px);
|
||||||
|
background-size: 40px 40px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
103
web/landing/src/components/HowItWorks.astro
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
---
|
||||||
|
const steps = [
|
||||||
|
{
|
||||||
|
number: '1',
|
||||||
|
title: 'Configura tus búsquedas',
|
||||||
|
description: 'Define tus criterios: término de búsqueda, rango de precios, ubicación, filtros avanzados y más.',
|
||||||
|
icon: '⚙️',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
number: '2',
|
||||||
|
title: 'Conecta Telegram',
|
||||||
|
description: 'Configura tu token de Telegram y el canal o chat donde quieres recibir las notificaciones.',
|
||||||
|
icon: '📱',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
number: '3',
|
||||||
|
title: 'Ejecuta Wallabicher',
|
||||||
|
description: 'Inicia el monitor y deja que trabaje en segundo plano. Revisará las plataformas automáticamente.',
|
||||||
|
icon: '🚀',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
number: '4',
|
||||||
|
title: 'Recibe notificaciones',
|
||||||
|
description: 'Cuando aparezca un artículo que coincida con tus filtros, recibirás una notificación instantánea con toda la información.',
|
||||||
|
icon: '🔔',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
---
|
||||||
|
|
||||||
|
<section id="como-funciona" class="py-24 sm:py-32 bg-white dark:bg-gray-950 relative overflow-hidden">
|
||||||
|
<!-- Background decoration -->
|
||||||
|
<div class="absolute inset-0">
|
||||||
|
<div class="absolute top-0 left-1/4 w-96 h-96 bg-cyan-100/20 dark:bg-cyan-900/20 rounded-full blur-3xl"></div>
|
||||||
|
<div class="absolute bottom-0 right-1/4 w-96 h-96 bg-primary-100/20 dark:bg-primary-900/20 rounded-full blur-3xl"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="relative z-10 max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
|
<div class="text-center mb-16 animate-fade-in">
|
||||||
|
<h2 class="text-4xl sm:text-5xl lg:text-6xl font-bold text-gray-900 dark:text-gray-50 mb-4">
|
||||||
|
¿Cómo
|
||||||
|
<span class="block text-transparent bg-clip-text bg-gradient-to-r from-primary-600 via-teal-600 to-cyan-600 dark:from-primary-400 dark:via-teal-400 dark:to-cyan-400">
|
||||||
|
Funciona?
|
||||||
|
</span>
|
||||||
|
</h2>
|
||||||
|
<p class="text-xl text-gray-600 dark:text-gray-400 max-w-2xl mx-auto">
|
||||||
|
Configuración simple en 4 pasos
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="relative">
|
||||||
|
<!-- Connection line (desktop only) -->
|
||||||
|
<div class="hidden lg:block absolute top-16 left-0 right-0 h-0.5 bg-gradient-to-r from-primary-200 via-teal-200 to-cyan-200 dark:from-primary-800 dark:via-teal-800 dark:to-cyan-800"></div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-8">
|
||||||
|
{steps.map((step, index) => (
|
||||||
|
<div class="relative animate-slide-up" style={`animation-delay: ${0.1 + index * 0.1}s`}>
|
||||||
|
<!-- Step card -->
|
||||||
|
<div class="group relative">
|
||||||
|
<!-- Connection dot (desktop only) -->
|
||||||
|
<div class="hidden lg:block absolute top-16 left-1/2 -translate-x-1/2 w-4 h-4 bg-gradient-to-br from-primary-500 to-teal-500 rounded-full z-10 ring-4 ring-white dark:ring-gray-950"></div>
|
||||||
|
|
||||||
|
<div class="relative pt-20 lg:pt-0">
|
||||||
|
<!-- Icon circle -->
|
||||||
|
<div class="flex justify-center mb-6 lg:mb-8">
|
||||||
|
<div class="relative">
|
||||||
|
<div class="absolute inset-0 bg-gradient-to-br from-primary-500 to-teal-500 rounded-full blur-xl opacity-50 group-hover:opacity-75 transition-opacity"></div>
|
||||||
|
<div class="relative w-20 h-20 lg:w-24 lg:h-24 rounded-full bg-gradient-to-br from-primary-600 via-teal-600 to-cyan-600 dark:from-primary-500 dark:via-teal-500 dark:to-cyan-500 flex items-center justify-center shadow-xl group-hover:scale-110 transition-transform duration-300">
|
||||||
|
<span class="text-3xl lg:text-4xl">{step.icon}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Number badge -->
|
||||||
|
<div class="absolute top-0 left-1/2 -translate-x-1/2 lg:top-4 lg:left-4 w-12 h-12 rounded-full bg-gradient-to-br from-primary-600 to-teal-600 dark:from-primary-500 dark:to-teal-500 text-white text-xl font-bold flex items-center justify-center shadow-lg ring-4 ring-white dark:ring-gray-950 z-20">
|
||||||
|
{step.number}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Content -->
|
||||||
|
<div class="text-center">
|
||||||
|
<h3 class="text-xl font-bold text-gray-900 dark:text-gray-50 mb-3 group-hover:text-primary-600 dark:group-hover:text-primary-400 transition-colors">
|
||||||
|
{step.title}
|
||||||
|
</h3>
|
||||||
|
<p class="text-gray-600 dark:text-gray-400 leading-relaxed">
|
||||||
|
{step.description}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Arrow (desktop only, except last) -->
|
||||||
|
{index < steps.length - 1 && (
|
||||||
|
<div class="hidden lg:block absolute top-16 left-full w-full">
|
||||||
|
<div class="relative h-0.5 bg-gradient-to-r from-primary-200 to-teal-200 dark:from-primary-800 dark:to-teal-800">
|
||||||
|
<div class="absolute right-0 top-1/2 -translate-y-1/2 w-0 h-0 border-l-8 border-l-teal-200 dark:border-l-teal-800 border-t-4 border-t-transparent border-b-4 border-b-transparent"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
103
web/landing/src/components/Platforms.astro
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
---
|
||||||
|
const platforms = [
|
||||||
|
{
|
||||||
|
name: 'Wallapop',
|
||||||
|
status: 'Totalmente funcional',
|
||||||
|
statusColor: 'bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400',
|
||||||
|
icon: '🟢',
|
||||||
|
description: 'Monitoriza búsquedas en Wallapop con todos los filtros disponibles y notificaciones en tiempo real.',
|
||||||
|
gradient: 'from-primary-500 to-primary-600',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Vinted',
|
||||||
|
status: 'Implementado',
|
||||||
|
statusColor: 'bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-400',
|
||||||
|
icon: '🟢',
|
||||||
|
description: 'Soporte para múltiples países. Requiere intervalos de búsqueda más largos para evitar bloqueos.',
|
||||||
|
gradient: 'from-teal-500 to-teal-600',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Buyee',
|
||||||
|
status: 'Por implementar',
|
||||||
|
statusColor: 'bg-yellow-100 dark:bg-yellow-900/30 text-yellow-700 dark:text-yellow-400',
|
||||||
|
icon: '🟡',
|
||||||
|
description: 'Próximamente disponible. Arquitectura extensible lista para nuevas plataformas.',
|
||||||
|
gradient: 'from-gray-400 to-gray-500',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
---
|
||||||
|
|
||||||
|
<section class="py-24 sm:py-32 bg-gradient-to-b from-white via-gray-50 to-white dark:from-gray-950 dark:via-gray-900 dark:to-gray-950 relative overflow-hidden">
|
||||||
|
<!-- Background decoration -->
|
||||||
|
<div class="absolute inset-0">
|
||||||
|
<div class="absolute top-1/4 left-0 w-72 h-72 bg-primary-200/20 dark:bg-primary-900/20 rounded-full blur-3xl"></div>
|
||||||
|
<div class="absolute bottom-1/4 right-0 w-72 h-72 bg-teal-200/20 dark:bg-teal-900/20 rounded-full blur-3xl"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="relative z-10 max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
|
<div class="text-center mb-16 animate-fade-in">
|
||||||
|
<h2 class="text-4xl sm:text-5xl lg:text-6xl font-bold text-gray-900 dark:text-gray-50 mb-4">
|
||||||
|
Plataformas
|
||||||
|
<span class="block text-transparent bg-clip-text bg-gradient-to-r from-primary-600 via-teal-600 to-cyan-600 dark:from-primary-400 dark:via-teal-400 dark:to-cyan-400">
|
||||||
|
Soportadas
|
||||||
|
</span>
|
||||||
|
</h2>
|
||||||
|
<p class="text-xl text-gray-600 dark:text-gray-400 max-w-2xl mx-auto">
|
||||||
|
Arquitectura extensible que permite añadir nuevas plataformas fácilmente
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-3 gap-8 mb-12">
|
||||||
|
{platforms.map((platform, index) => (
|
||||||
|
<div
|
||||||
|
class="group relative p-8 rounded-2xl bg-white dark:bg-gray-900 border-2 border-gray-200 dark:border-gray-800 hover:border-primary-300 dark:hover:border-primary-700 transition-all duration-300 transform hover:-translate-y-2 hover:shadow-2xl animate-slide-up"
|
||||||
|
style={`animation-delay: ${0.1 + index * 0.1}s`}
|
||||||
|
>
|
||||||
|
<!-- Gradient background on hover -->
|
||||||
|
<div class={`absolute inset-0 bg-gradient-to-br ${platform.gradient} opacity-0 group-hover:opacity-5 rounded-2xl transition-opacity duration-300`}></div>
|
||||||
|
|
||||||
|
<div class="relative z-10">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="flex items-center justify-between mb-6">
|
||||||
|
<h3 class="text-2xl font-bold text-gray-900 dark:text-gray-50">{platform.name}</h3>
|
||||||
|
<span class="text-3xl transform group-hover:scale-110 transition-transform duration-300">{platform.icon}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Status badge -->
|
||||||
|
<span class={`inline-block px-3 py-1 rounded-full text-xs font-semibold mb-4 ${platform.statusColor}`}>
|
||||||
|
{platform.status}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<!-- Description -->
|
||||||
|
<p class="text-gray-600 dark:text-gray-400 leading-relaxed">
|
||||||
|
{platform.description}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Decorative element -->
|
||||||
|
<div class={`absolute bottom-0 right-0 w-24 h-24 bg-gradient-to-br ${platform.gradient} opacity-0 group-hover:opacity-10 rounded-tl-full transition-opacity duration-300`}></div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- CTA -->
|
||||||
|
<div class="text-center animate-fade-in" style="animation-delay: 0.4s;">
|
||||||
|
<div class="inline-block p-8 rounded-2xl bg-gradient-to-br from-primary-50 to-teal-50 dark:from-gray-900 dark:to-gray-800 border border-primary-200 dark:border-primary-800">
|
||||||
|
<p class="text-lg text-gray-700 dark:text-gray-300 mb-4 font-medium">
|
||||||
|
¿Quieres añadir una nueva plataforma?
|
||||||
|
</p>
|
||||||
|
<a
|
||||||
|
href="https://github.com"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
class="inline-flex items-center text-primary-600 dark:text-primary-400 hover:text-primary-700 dark:hover:text-primary-300 font-semibold group"
|
||||||
|
>
|
||||||
|
Consulta la documentación
|
||||||
|
<svg class="w-5 h-5 ml-2 transform group-hover:translate-x-1 transition-transform" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"></path>
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
231
web/landing/src/components/Pricing.astro
Normal file
@@ -0,0 +1,231 @@
|
|||||||
|
---
|
||||||
|
const plans = [
|
||||||
|
{
|
||||||
|
id: 'free',
|
||||||
|
name: 'Gratis',
|
||||||
|
description: 'Perfecto para empezar',
|
||||||
|
price: { monthly: 0, yearly: 0 },
|
||||||
|
popular: false,
|
||||||
|
features: [
|
||||||
|
'Hasta 2 búsquedas simultáneas',
|
||||||
|
'Solo Wallapop',
|
||||||
|
'50 notificaciones por día',
|
||||||
|
'Soporte por email',
|
||||||
|
],
|
||||||
|
limits: {
|
||||||
|
workers: 2,
|
||||||
|
notifications: 50,
|
||||||
|
platforms: ['Wallapop'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'basic',
|
||||||
|
name: 'Básico',
|
||||||
|
description: 'Para usuarios ocasionales',
|
||||||
|
price: { monthly: 9.99, yearly: 99.99 },
|
||||||
|
popular: true,
|
||||||
|
features: [
|
||||||
|
'Hasta 5 búsquedas simultáneas',
|
||||||
|
'Wallapop y Vinted',
|
||||||
|
'200 notificaciones por día',
|
||||||
|
'Soporte prioritario',
|
||||||
|
'Sin límite de favoritos',
|
||||||
|
],
|
||||||
|
limits: {
|
||||||
|
workers: 5,
|
||||||
|
notifications: 200,
|
||||||
|
platforms: ['Wallapop', 'Vinted'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'pro',
|
||||||
|
name: 'Pro',
|
||||||
|
description: 'Para usuarios avanzados',
|
||||||
|
price: { monthly: 19.99, yearly: 199.99 },
|
||||||
|
popular: false,
|
||||||
|
features: [
|
||||||
|
'Hasta 15 búsquedas simultáneas',
|
||||||
|
'Todas las plataformas',
|
||||||
|
'1000 notificaciones por día',
|
||||||
|
'Soporte prioritario 24/7',
|
||||||
|
'API access',
|
||||||
|
'Webhooks personalizados',
|
||||||
|
],
|
||||||
|
limits: {
|
||||||
|
workers: 15,
|
||||||
|
notifications: 1000,
|
||||||
|
platforms: ['Wallapop', 'Vinted', 'Buyee'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'enterprise',
|
||||||
|
name: 'Enterprise',
|
||||||
|
description: 'Para equipos y uso intensivo',
|
||||||
|
price: { monthly: 49.99, yearly: 499.99 },
|
||||||
|
popular: false,
|
||||||
|
features: [
|
||||||
|
'Búsquedas ilimitadas',
|
||||||
|
'Notificaciones ilimitadas',
|
||||||
|
'Todas las plataformas',
|
||||||
|
'Soporte dedicado',
|
||||||
|
'API completa',
|
||||||
|
'Webhooks personalizados',
|
||||||
|
'Gestión de múltiples usuarios',
|
||||||
|
'Estadísticas avanzadas',
|
||||||
|
],
|
||||||
|
limits: {
|
||||||
|
workers: 'Ilimitado',
|
||||||
|
notifications: 'Ilimitado',
|
||||||
|
platforms: ['Todas'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
---
|
||||||
|
|
||||||
|
<section id="precios" class="py-24 sm:py-32 bg-gradient-to-b from-white via-gray-50 to-white dark:from-gray-950 dark:via-gray-900 dark:to-gray-950 relative overflow-hidden">
|
||||||
|
<!-- Background decoration -->
|
||||||
|
<div class="absolute inset-0">
|
||||||
|
<div class="absolute top-0 left-1/4 w-96 h-96 bg-primary-200/20 dark:bg-primary-900/20 rounded-full blur-3xl"></div>
|
||||||
|
<div class="absolute bottom-0 right-1/4 w-96 h-96 bg-teal-200/20 dark:bg-teal-900/20 rounded-full blur-3xl"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="relative z-10 max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
|
<div class="text-center mb-16 animate-fade-in">
|
||||||
|
<h2 class="text-4xl sm:text-5xl lg:text-6xl font-bold text-gray-900 dark:text-gray-50 mb-4">
|
||||||
|
Planes y
|
||||||
|
<span class="block text-transparent bg-clip-text bg-gradient-to-r from-primary-600 via-teal-600 to-cyan-600 dark:from-primary-400 dark:via-teal-400 dark:to-cyan-400">
|
||||||
|
Precios
|
||||||
|
</span>
|
||||||
|
</h2>
|
||||||
|
<p class="text-xl text-gray-600 dark:text-gray-400 max-w-2xl mx-auto">
|
||||||
|
Elige el plan que mejor se adapte a tus necesidades
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Toggle mensual/anual -->
|
||||||
|
<div class="flex justify-center mb-12">
|
||||||
|
<div class="inline-flex items-center bg-gray-100 dark:bg-gray-800 rounded-lg p-1">
|
||||||
|
<button id="billing-monthly" class="px-4 py-2 rounded-md text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 shadow-sm transition-all">
|
||||||
|
Mensual
|
||||||
|
</button>
|
||||||
|
<button id="billing-yearly" class="px-4 py-2 rounded-md text-sm font-medium text-gray-700 dark:text-gray-300 transition-all">
|
||||||
|
Anual
|
||||||
|
<span class="ml-1 text-xs text-primary-600 dark:text-primary-400">-17%</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-8">
|
||||||
|
{plans.map((plan, index) => (
|
||||||
|
<div
|
||||||
|
class={`relative p-8 rounded-2xl bg-white dark:bg-gray-900 border-2 transition-all duration-300 transform hover:-translate-y-2 hover:shadow-2xl animate-slide-up ${
|
||||||
|
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">
|
||||||
|
{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"
|
||||||
|
class={`block w-full text-center px-6 py-3 rounded-xl font-semibold transition-all duration-300 ${
|
||||||
|
plan.popular
|
||||||
|
? 'bg-gradient-to-r from-primary-600 to-teal-600 text-white hover:from-primary-700 hover:to-teal-700 shadow-lg hover:shadow-xl'
|
||||||
|
: plan.id === 'free'
|
||||||
|
? 'bg-gray-100 dark:bg-gray-800 text-gray-900 dark:text-gray-100 hover:bg-gray-200 dark:hover:bg-gray-700'
|
||||||
|
: 'bg-primary-50 dark:bg-primary-900/30 text-primary-700 dark:text-primary-400 hover:bg-primary-100 dark:hover:bg-primary-900/50 border-2 border-primary-200 dark:border-primary-800'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{plan.id === 'free' ? 'Empezar gratis' : 'Elegir plan'}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- FAQ o nota adicional -->
|
||||||
|
<div class="mt-16 text-center">
|
||||||
|
<p class="text-gray-600 dark:text-gray-400 mb-4">
|
||||||
|
¿Necesitas un plan personalizado?
|
||||||
|
</p>
|
||||||
|
<a
|
||||||
|
href="mailto:soporte@wallabicher.com"
|
||||||
|
class="inline-flex items-center text-primary-600 dark:text-primary-400 hover:text-primary-700 dark:hover:text-primary-300 font-semibold"
|
||||||
|
>
|
||||||
|
Contacta con nosotros
|
||||||
|
<svg class="w-5 h-5 ml-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"></path>
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// Toggle entre mensual y anual
|
||||||
|
const monthlyBtn = document.getElementById('billing-monthly');
|
||||||
|
const yearlyBtn = document.getElementById('billing-yearly');
|
||||||
|
const monthlyPrices = document.querySelectorAll('.price-monthly');
|
||||||
|
const yearlyPrices = document.querySelectorAll('.price-yearly');
|
||||||
|
|
||||||
|
monthlyBtn?.addEventListener('click', () => {
|
||||||
|
monthlyBtn.classList.add('bg-white', 'dark:bg-gray-700', 'shadow-sm');
|
||||||
|
monthlyBtn.classList.remove('text-gray-700', 'dark:text-gray-300');
|
||||||
|
yearlyBtn.classList.remove('bg-white', 'dark:bg-gray-700', 'shadow-sm');
|
||||||
|
yearlyBtn.classList.add('text-gray-700', 'dark:text-gray-300');
|
||||||
|
monthlyPrices.forEach(p => p.classList.remove('hidden'));
|
||||||
|
yearlyPrices.forEach(p => p.classList.add('hidden'));
|
||||||
|
});
|
||||||
|
|
||||||
|
yearlyBtn?.addEventListener('click', () => {
|
||||||
|
yearlyBtn.classList.add('bg-white', 'dark:bg-gray-700', 'shadow-sm');
|
||||||
|
yearlyBtn.classList.remove('text-gray-700', 'dark:text-gray-300');
|
||||||
|
monthlyBtn.classList.remove('bg-white', 'dark:bg-gray-700', 'shadow-sm');
|
||||||
|
monthlyBtn.classList.add('text-gray-700', 'dark:text-gray-300');
|
||||||
|
yearlyPrices.forEach(p => p.classList.remove('hidden'));
|
||||||
|
monthlyPrices.forEach(p => p.classList.add('hidden'));
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
1
web/landing/src/env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
/// <reference path="../.astro/types.d.ts" />
|
||||||
63
web/landing/src/layouts/Layout.astro
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
---
|
||||||
|
interface Props {
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { title, description = "Automatiza tus búsquedas en marketplaces (Wallapop, Vinted, etc.) y recibe notificaciones instantáneas en Telegram cuando aparezcan nuevos artículos." } = Astro.props;
|
||||||
|
---
|
||||||
|
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="es">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="description" content={description} />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
|
<meta name="generator" content={Astro.generator} />
|
||||||
|
<meta name="theme-color" content="#06b6d4" />
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800;900&display=swap" rel="stylesheet" />
|
||||||
|
<title>{title}</title>
|
||||||
|
</head>
|
||||||
|
<body class="bg-white dark:bg-gray-950 text-gray-900 dark:text-gray-50 antialiased transition-colors duration-300">
|
||||||
|
<slot />
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
||||||
|
<style is:global>
|
||||||
|
html {
|
||||||
|
font-family: 'Inter', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||||
|
scroll-behavior: smooth;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
@apply border-gray-200 dark:border-gray-800;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Scrollbar styling */
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
@apply w-2 h-2;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-track {
|
||||||
|
@apply bg-gray-100 dark:bg-gray-900;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
@apply bg-gray-300 dark:bg-gray-700 rounded-full;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb:hover {
|
||||||
|
@apply bg-gray-400 dark:bg-gray-600;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Smooth transitions */
|
||||||
|
* {
|
||||||
|
transition-property: color, background-color, border-color;
|
||||||
|
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
transition-duration: 200ms;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
19
web/landing/src/pages/index.astro
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
---
|
||||||
|
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 HowItWorks from '../components/HowItWorks.astro';
|
||||||
|
import Pricing from '../components/Pricing.astro';
|
||||||
|
import CTA from '../components/CTA.astro';
|
||||||
|
---
|
||||||
|
|
||||||
|
<Layout title="Wallabicher - Monitoriza Marketplaces Automáticamente">
|
||||||
|
<Hero />
|
||||||
|
<Features />
|
||||||
|
<Platforms />
|
||||||
|
<HowItWorks />
|
||||||
|
<Pricing />
|
||||||
|
<CTA />
|
||||||
|
</Layout>
|
||||||
|
|
||||||
88
web/landing/tailwind.config.mjs
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
/** @type {import('tailwindcss').Config} */
|
||||||
|
export default {
|
||||||
|
content: ['./src/**/*.{astro,html,js,jsx,md,mdx,svelte,ts,tsx,vue}'],
|
||||||
|
darkMode: 'media', // Detecta automáticamente las preferencias del sistema
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
colors: {
|
||||||
|
// Colores basados en el logo teal/cyan
|
||||||
|
primary: {
|
||||||
|
50: '#ecfeff',
|
||||||
|
100: '#cffafe',
|
||||||
|
200: '#a5f3fc',
|
||||||
|
300: '#67e8f9',
|
||||||
|
400: '#22d3ee',
|
||||||
|
500: '#06b6d4', // Teal principal
|
||||||
|
600: '#0891b2',
|
||||||
|
700: '#0e7490',
|
||||||
|
800: '#155e75',
|
||||||
|
900: '#164e63',
|
||||||
|
950: '#083344',
|
||||||
|
},
|
||||||
|
teal: {
|
||||||
|
50: '#f0fdfa',
|
||||||
|
100: '#ccfbf1',
|
||||||
|
200: '#99f6e4',
|
||||||
|
300: '#5eead4',
|
||||||
|
400: '#2dd4bf',
|
||||||
|
500: '#14b8a6',
|
||||||
|
600: '#0d9488',
|
||||||
|
700: '#0f766e',
|
||||||
|
800: '#115e59',
|
||||||
|
900: '#134e4a',
|
||||||
|
},
|
||||||
|
gray: {
|
||||||
|
50: '#f9fafb',
|
||||||
|
100: '#f3f4f6',
|
||||||
|
200: '#e5e7eb',
|
||||||
|
300: '#d1d5db',
|
||||||
|
400: '#9ca3af',
|
||||||
|
500: '#6b7280',
|
||||||
|
600: '#4b5563',
|
||||||
|
700: '#374151',
|
||||||
|
800: '#1f2937',
|
||||||
|
900: '#111827',
|
||||||
|
950: '#030712',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
fontFamily: {
|
||||||
|
sans: ['Inter', 'system-ui', '-apple-system', 'BlinkMacSystemFont', 'Segoe UI', 'Roboto', 'sans-serif'],
|
||||||
|
},
|
||||||
|
animation: {
|
||||||
|
'fade-in': 'fadeIn 0.6s ease-out',
|
||||||
|
'slide-up': 'slideUp 0.6s ease-out',
|
||||||
|
'slide-in-right': 'slideInRight 0.6s ease-out',
|
||||||
|
'float': 'float 6s ease-in-out infinite',
|
||||||
|
'gradient': 'gradient 15s ease infinite',
|
||||||
|
},
|
||||||
|
keyframes: {
|
||||||
|
fadeIn: {
|
||||||
|
'0%': { opacity: '0' },
|
||||||
|
'100%': { opacity: '1' },
|
||||||
|
},
|
||||||
|
slideUp: {
|
||||||
|
'0%': { transform: 'translateY(20px)', opacity: '0' },
|
||||||
|
'100%': { transform: 'translateY(0)', opacity: '1' },
|
||||||
|
},
|
||||||
|
slideInRight: {
|
||||||
|
'0%': { transform: 'translateX(-20px)', opacity: '0' },
|
||||||
|
'100%': { transform: 'translateX(0)', opacity: '1' },
|
||||||
|
},
|
||||||
|
float: {
|
||||||
|
'0%, 100%': { transform: 'translateY(0px)' },
|
||||||
|
'50%': { transform: 'translateY(-20px)' },
|
||||||
|
},
|
||||||
|
gradient: {
|
||||||
|
'0%, 100%': { backgroundPosition: '0% 50%' },
|
||||||
|
'50%': { backgroundPosition: '100% 50%' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
backgroundImage: {
|
||||||
|
'gradient-radial': 'radial-gradient(var(--tw-gradient-stops))',
|
||||||
|
'gradient-conic': 'conic-gradient(from 180deg at 50% 50%, var(--tw-gradient-stops))',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: [],
|
||||||
|
}
|
||||||
|
|
||||||
8
web/landing/tsconfig.json
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"extends": "astro/tsconfigs/strict",
|
||||||
|
"compilerOptions": {
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"jsxImportSource": "react"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
76
web/nginx.conf
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name localhost;
|
||||||
|
|
||||||
|
# Gzip compression
|
||||||
|
gzip on;
|
||||||
|
gzip_vary on;
|
||||||
|
gzip_min_length 1024;
|
||||||
|
gzip_types text/plain text/css text/xml text/javascript application/x-javascript application/xml+rss application/json application/javascript;
|
||||||
|
|
||||||
|
# API proxy - debe ir antes de otras rutas
|
||||||
|
location /api {
|
||||||
|
proxy_pass http://backend:3001;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection 'upgrade';
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_cache_bypass $http_upgrade;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
|
||||||
|
# WebSocket proxy
|
||||||
|
location /ws {
|
||||||
|
proxy_pass http://backend:3001;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection "upgrade";
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Login también va al dashboard
|
||||||
|
location /login {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Dashboard Vue
|
||||||
|
location /dashboard {
|
||||||
|
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;
|
||||||
|
|
||||||
|
# Para SPA routing dentro de /dashboard - quitar el prefijo /dashboard
|
||||||
|
rewrite ^/dashboard/(.*)$ /$1 break;
|
||||||
|
rewrite ^/dashboard$ / break;
|
||||||
|
}
|
||||||
|
|
||||||
|
# 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Cache static assets
|
||||||
|
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
|
||||||
|
expires 1y;
|
||||||
|
add_header Cache-Control "public, immutable";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
20
web/start.sh
@@ -25,10 +25,10 @@ if [ ! -d "backend/node_modules" ]; then
|
|||||||
cd ..
|
cd ..
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Instalar dependencias del frontend si no existen
|
# Instalar dependencias del dashboard si no existen
|
||||||
if [ ! -d "frontend/node_modules" ]; then
|
if [ ! -d "dashboard/node_modules" ]; then
|
||||||
echo "📦 Instalando dependencias del frontend..."
|
echo "📦 Instalando dependencias del dashboard..."
|
||||||
cd frontend
|
cd dashboard
|
||||||
npm install
|
npm install
|
||||||
cd ..
|
cd ..
|
||||||
fi
|
fi
|
||||||
@@ -43,21 +43,21 @@ cd ..
|
|||||||
# Esperar un poco para que el backend se inicie
|
# Esperar un poco para que el backend se inicie
|
||||||
sleep 2
|
sleep 2
|
||||||
|
|
||||||
# Iniciar frontend
|
# Iniciar dashboard
|
||||||
echo "🎨 Iniciando frontend..."
|
echo "🎨 Iniciando dashboard..."
|
||||||
cd frontend
|
cd dashboard
|
||||||
npm run dev &
|
npm run dev &
|
||||||
FRONTEND_PID=$!
|
DASHBOARD_PID=$!
|
||||||
cd ..
|
cd ..
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
echo "✅ Servidores iniciados!"
|
echo "✅ Servidores iniciados!"
|
||||||
echo "📡 Backend: http://localhost:3001"
|
echo "📡 Backend: http://localhost:3001"
|
||||||
echo "🎨 Frontend: http://localhost:3000"
|
echo "🎨 Dashboard: http://localhost:3000"
|
||||||
echo ""
|
echo ""
|
||||||
echo "Presiona Ctrl+C para detener los servidores"
|
echo "Presiona Ctrl+C para detener los servidores"
|
||||||
|
|
||||||
# Esperar a que se presione Ctrl+C
|
# Esperar a que se presione Ctrl+C
|
||||||
trap "kill $BACKEND_PID $FRONTEND_PID 2>/dev/null; exit" INT TERM
|
trap "kill $BACKEND_PID $DASHBOARD_PID 2>/dev/null; exit" INT TERM
|
||||||
wait
|
wait
|
||||||
|
|
||||||
|
|||||||