Spaces:
Build error
Build error
Gmagl commited on
Commit ·
3eebcd0
1
Parent(s): b5b5794
fix: correcciones críticas y refactorización de componentes
Browse files- Fix TypeScript: Stripe current_period_end -> items.data[0]
- Fix Stripe apiVersion -> 2026-02-25.clover
- Fix subscriptions.del -> subscriptions.cancel
- Instalar eslint + eslint-config-next
- Conectar botón Videos a /api/generate/video
- Unificar toast a Sonner
- Eliminar lib/ai.ts (código muerto)
- Eliminar import path no usado en next.config.ts
- Refactorizar page.tsx (1131 -> ~120 líneas)
- 12 componentes en src/components/dashboard/
- Actualizar Dockerfile para PostgreSQL + standalone
- API_USAGE.md +270 -0
- Dockerfile +3 -8
- MIGRATION_GUIDE.md +257 -0
- STRIPE_SETUP_SUMMARY.md +245 -0
- STRIPE_TESTING_GUIDE.md +156 -0
- VERIFICATION_CHECKLIST.md +126 -0
- entrypoint.sh +3 -5
- next.config.ts +0 -2
- package-lock.json +0 -0
- package.json +4 -1
- prisma/schema.prisma +45 -12
- src/app/api/generate/image/route.ts +22 -6
- src/app/api/influencers/report/route.ts +115 -0
- src/app/api/influencers/route.ts +95 -11
- src/app/api/influencers/subscription/cancel/route.ts +60 -0
- src/app/api/influencers/subscription/route.ts +89 -0
- src/app/api/payments/checkout/route.ts +45 -0
- src/app/api/payments/stripe-webhook/route.ts +40 -0
- src/app/api/payments/webhook/route.ts +207 -0
- src/app/api/storytelling/route.ts +20 -4
- src/app/layout.tsx +2 -2
- src/app/page.tsx +55 -1053
- src/components/dashboard/AutomationTab.tsx +99 -0
- src/components/dashboard/ContentTab.tsx +36 -0
- src/components/dashboard/DashboardSidebar.tsx +72 -0
- src/components/dashboard/ImagesTab.tsx +90 -0
- src/components/dashboard/InfluencersTab.tsx +160 -0
- src/components/dashboard/MonetizationTab.tsx +38 -0
- src/components/dashboard/PetsTab.tsx +137 -0
- src/components/dashboard/PostsTab.tsx +97 -0
- src/components/dashboard/PromptEngineerTab.tsx +148 -0
- src/components/dashboard/StorytellingTab.tsx +98 -0
- src/components/dashboard/TrendsTab.tsx +168 -0
- src/components/dashboard/VideosTab.tsx +44 -0
- src/components/dashboard/index.ts +12 -0
- src/components/dashboard/types.ts +49 -0
- src/lib/ai.ts +0 -54
- src/lib/credits.ts +138 -0
- src/lib/stripe.ts +102 -0
API_USAGE.md
ADDED
|
@@ -0,0 +1,270 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Sofia-Cloud: Sistema de Créditos y API
|
| 2 |
+
|
| 3 |
+
## 🎯 Resumen Rápido
|
| 4 |
+
|
| 5 |
+
Sofia-Cloud ahora tiene:
|
| 6 |
+
- ✅ **Creación automática de influencers** (análisis + generación + almacenamiento en 1 llamada)
|
| 7 |
+
- ✅ **Límites permanentes por tier** (NO se resetean mensualmente)
|
| 8 |
+
- ✅ **Rate-limiting inteligente** para proteger tu inversión
|
| 9 |
+
- ✅ **Tracking de uso acumulativo**
|
| 10 |
+
|
| 11 |
+
## 📊 Límites por Tier (PERMANENTES)
|
| 12 |
+
|
| 13 |
+
Los límites son parte de la suscripción pagada. **NO se resetean cada mes**. Se resetean solo si:
|
| 14 |
+
|
| 15 |
+
1. El cliente sube a un tier superior
|
| 16 |
+
2. O solicita manualmente un reset (si paga extra)
|
| 17 |
+
|
| 18 |
+
| Recurso | Free | Basic | Premium | Pro |
|
| 19 |
+
| --- | --- | --- | --- | --- |
|
| 20 |
+
| Influencers | 2 | 10 | 50 | 500 |
|
| 21 |
+
| Stories | 3 | 15 | 60 | 999 |
|
| 22 |
+
| Imágenes | 5 | 30 | 150 | 999 |
|
| 23 |
+
| Videos | 1 | 5 | 20 | 999 |
|
| 24 |
+
| Contenido Total | 10 | 50 | 300 | 999 |
|
| 25 |
+
|
| 26 |
+
## Ejemplo
|
| 27 |
+
|
| 28 |
+
- Cliente contrata **Basic** = 10 influencers de por vida con Basic
|
| 29 |
+
- Cuando haya usado los 10, recibe error "Necesitas subir a un tier superior"
|
| 30 |
+
- Si sube a **Premium** = ahora tiene 50 influencers totales (no 60, mantiene los 10 anteriores en el nuevo límite)
|
| 31 |
+
|
| 32 |
+
## 🔧 Cómo Usar
|
| 33 |
+
|
| 34 |
+
### 1️⃣ Crear Influencer (Automático)
|
| 35 |
+
|
| 36 |
+
```bash
|
| 37 |
+
curl -X POST http://localhost:7860/api/influencers \
|
| 38 |
+
-H "Content-Type: application/json" \
|
| 39 |
+
-d '{
|
| 40 |
+
"targetNiche": "Fashion",
|
| 41 |
+
"includePets": true,
|
| 42 |
+
"userId": "user123",
|
| 43 |
+
"tier": "premium"
|
| 44 |
+
}'
|
| 45 |
+
```
|
| 46 |
+
|
| 47 |
+
**Respuesta exitosa:**
|
| 48 |
+
```json
|
| 49 |
+
{
|
| 50 |
+
"success": true,
|
| 51 |
+
"influencer": {
|
| 52 |
+
"id": "cid123",
|
| 53 |
+
"name": "Luna Style",
|
| 54 |
+
"handle": "@lunastyle",
|
| 55 |
+
"platform": "Instagram",
|
| 56 |
+
"niche": "Fashion",
|
| 57 |
+
"followers": 250000,
|
| 58 |
+
"engagement": 3.5,
|
| 59 |
+
"petCompanion": true,
|
| 60 |
+
"petType": "dog"
|
| 61 |
+
},
|
| 62 |
+
"creditsRemaining": 49
|
| 63 |
+
}
|
| 64 |
+
```
|
| 65 |
+
|
| 66 |
+
**Si no hay créditos:**
|
| 67 |
+
```json
|
| 68 |
+
{
|
| 69 |
+
"success": false,
|
| 70 |
+
"error": "Límite mensual de influencers_per_month alcanzado (50). Intenta el próximo mes.",
|
| 71 |
+
"remaining": 0
|
| 72 |
+
}
|
| 73 |
+
```
|
| 74 |
+
|
| 75 |
+
---
|
| 76 |
+
|
| 77 |
+
### 2️⃣ Crear Story (Con Validación)
|
| 78 |
+
|
| 79 |
+
```bash
|
| 80 |
+
curl -X POST http://localhost:7860/api/storytelling \
|
| 81 |
+
-H "Content-Type: application/json" \
|
| 82 |
+
-d '{
|
| 83 |
+
"prompt": "Una influencer de moda que viaja por el mundo",
|
| 84 |
+
"genre": "lifestyle",
|
| 85 |
+
"totalEpisodes": 10,
|
| 86 |
+
"userId": "user123",
|
| 87 |
+
"tier": "premium"
|
| 88 |
+
}'
|
| 89 |
+
```
|
| 90 |
+
|
| 91 |
+
**Respuesta:**
|
| 92 |
+
```json
|
| 93 |
+
{
|
| 94 |
+
"success": true,
|
| 95 |
+
"story": {
|
| 96 |
+
"id": "story123",
|
| 97 |
+
"title": "Luna Viajera",
|
| 98 |
+
"description": "Una influencer de moda...",
|
| 99 |
+
"genre": "lifestyle",
|
| 100 |
+
"totalEpisodes": 10,
|
| 101 |
+
"status": "draft",
|
| 102 |
+
"episodes": [
|
| 103 |
+
{
|
| 104 |
+
"id": "ep1",
|
| 105 |
+
"episodeNum": 1,
|
| 106 |
+
"title": "Capítulo 1: El Comienzo",
|
| 107 |
+
"content": "..."
|
| 108 |
+
}
|
| 109 |
+
]
|
| 110 |
+
},
|
| 111 |
+
"creditsRemaining": 59
|
| 112 |
+
}
|
| 113 |
+
```
|
| 114 |
+
|
| 115 |
+
---
|
| 116 |
+
|
| 117 |
+
### 3️⃣ Generar Imagen (Con Límite)
|
| 118 |
+
|
| 119 |
+
```bash
|
| 120 |
+
curl -X POST http://localhost:7860/api/generate/image \
|
| 121 |
+
-H "Content-Type: application/json" \
|
| 122 |
+
-d '{
|
| 123 |
+
"prompt": "Una modelo de moda en París",
|
| 124 |
+
"platform": "instagram",
|
| 125 |
+
"style": "realistic",
|
| 126 |
+
"userId": "user123",
|
| 127 |
+
"tier": "basic"
|
| 128 |
+
}'
|
| 129 |
+
```
|
| 130 |
+
|
| 131 |
+
---
|
| 132 |
+
|
| 133 |
+
## 🚨 Manejo de Errores
|
| 134 |
+
|
| 135 |
+
### Créditos agotados (429)
|
| 136 |
+
```json
|
| 137 |
+
{
|
| 138 |
+
"success": false,
|
| 139 |
+
"error": "Límite mensual de influencers_per_month alcanzado (10). Intenta el próximo mes.",
|
| 140 |
+
"remaining": 0
|
| 141 |
+
}
|
| 142 |
+
```
|
| 143 |
+
|
| 144 |
+
### Falta prompty (400)
|
| 145 |
+
```json
|
| 146 |
+
{
|
| 147 |
+
"success": false,
|
| 148 |
+
"error": "Prompt requerido"
|
| 149 |
+
}
|
| 150 |
+
```
|
| 151 |
+
|
| 152 |
+
### Error de API (500)
|
| 153 |
+
```json
|
| 154 |
+
{
|
| 155 |
+
"success": false,
|
| 156 |
+
"error": "Error al conectar con ZAI"
|
| 157 |
+
}
|
| 158 |
+
```
|
| 159 |
+
|
| 160 |
+
---
|
| 161 |
+
|
| 162 |
+
## 💡 Mejores Prácticas
|
| 163 |
+
|
| 164 |
+
### 1. Siempre incluir userId y tier
|
| 165 |
+
```typescript
|
| 166 |
+
// ❌ Malo
|
| 167 |
+
POST /api/influencers
|
| 168 |
+
{ "targetNiche": "Fashion" }
|
| 169 |
+
|
| 170 |
+
// ✅ Bueno
|
| 171 |
+
POST /api/influencers
|
| 172 |
+
{
|
| 173 |
+
"targetNiche": "Fashion",
|
| 174 |
+
"userId": "user123",
|
| 175 |
+
"tier": "premium"
|
| 176 |
+
}
|
| 177 |
+
```
|
| 178 |
+
|
| 179 |
+
### 2. Revisar creditsRemaining antes de siguiente acción
|
| 180 |
+
```typescript
|
| 181 |
+
if (response.creditsRemaining === 0) {
|
| 182 |
+
showUpgradePrompt(); // Sugerir upgrade
|
| 183 |
+
}
|
| 184 |
+
```
|
| 185 |
+
|
| 186 |
+
### 3. Manejar erro 429 (sin créditos)
|
| 187 |
+
```typescript
|
| 188 |
+
if (error.status === 429) {
|
| 189 |
+
showMessage("Límite mensual alcanzado. Intenta mañana o upgrade tu plan.");
|
| 190 |
+
}
|
| 191 |
+
```
|
| 192 |
+
|
| 193 |
+
---
|
| 194 |
+
|
| 195 |
+
## 📝 Notas Técnicas
|
| 196 |
+
|
| 197 |
+
- Los límites son **PERMANENTES** por el tier contratado (no se resetean mensualmente).
|
| 198 |
+
- El conteo incluye TODOS los recursos creados; para resetearse debe realizarse un cambio de tier o un reset manual pago.
|
| 199 |
+
- Si hay error en las APIs, se permite la acción (fallsafe seguro) para no bloquear al usuario por fallos externos.
|
| 200 |
+
- Los créditos se verifican ANTES de hacer la llamada (no gastas en errores).
|
| 201 |
+
|
| 202 |
+
---
|
| 203 |
+
|
| 204 |
+
## 🧾 Suscripciones por influencer
|
| 205 |
+
|
| 206 |
+
Cada cliente debe suscribirse individualmente a cada influencer con la que quiera interactuar en chat privado o contenido de pago. Las suscripciones son por influencer y por tier. Ejemplos:
|
| 207 |
+
|
| 208 |
+
- Usuario `user123` se suscribe a la influencer `Luna Style` con el tier `basic`.
|
| 209 |
+
- Si quiere interactuar también con `Ayla`, debe suscribirse por separado a `Ayla`.
|
| 210 |
+
|
| 211 |
+
### Endpoints
|
| 212 |
+
|
| 213 |
+
- Suscribirse:
|
| 214 |
+
|
| 215 |
+
```bash
|
| 216 |
+
curl -X POST http://localhost:7860/api/influencers/subscription \
|
| 217 |
+
-H "Content-Type: application/json" \
|
| 218 |
+
-d '{"userId":"user123","influencerId":"<influencer-id>","tier":"basic","price":9.99}'
|
| 219 |
+
```
|
| 220 |
+
|
| 221 |
+
- Listar suscripciones (por usuario o por influencer):
|
| 222 |
+
|
| 223 |
+
```bash
|
| 224 |
+
curl "http://localhost:7860/api/influencers/subscription?userId=user123"
|
| 225 |
+
curl "http://localhost:7860/api/influencers/subscription?influencerId=<influencer-id>"
|
| 226 |
+
```
|
| 227 |
+
|
| 228 |
+
- Cancelar/Anular suscripción:
|
| 229 |
+
|
| 230 |
+
```bash
|
| 231 |
+
curl -X DELETE "http://localhost:7860/api/influencers/subscription?id=<subscription-id>"
|
| 232 |
+
```
|
| 233 |
+
|
| 234 |
+
---
|
| 235 |
+
|
| 236 |
+
## 🎨 Qué Genera Automáticamente
|
| 237 |
+
|
| 238 |
+
### Influencer (POST /api/influencers)
|
| 239 |
+
- ✅ Nombre único
|
| 240 |
+
- ✅ Handle/Username
|
| 241 |
+
- ✅ Platform (Instagram/TikTok/YouTube)
|
| 242 |
+
- ✅ Follower count estimado
|
| 243 |
+
- ✅ Engagement rate
|
| 244 |
+
- ✅ Niche específico
|
| 245 |
+
- ✅ Estilo visual
|
| 246 |
+
- ✅ Tipos de contenido
|
| 247 |
+
- ✅ Schedule de posts
|
| 248 |
+
- ✅ Tipo de monetización
|
| 249 |
+
- ✅ ¿Mascota? + tipo
|
| 250 |
+
|
| 251 |
+
### Story (POST /api/storytelling)
|
| 252 |
+
- ✅ Título atractivo
|
| 253 |
+
- ✅ Sinopsis completa
|
| 254 |
+
- ✅ 3-10 episodios con contenido
|
| 255 |
+
- ✅ Metadata de análisis
|
| 256 |
+
|
| 257 |
+
### Imagen (POST /api/generate/image)
|
| 258 |
+
- ✅ Imagen 1024x1024 en base64
|
| 259 |
+
- ✅ Prompt optimizado
|
| 260 |
+
- ✅ Validación de censura por plataforma
|
| 261 |
+
|
| 262 |
+
---
|
| 263 |
+
|
| 264 |
+
## ⚡ Próximas Mejoras (Sin romper)
|
| 265 |
+
|
| 266 |
+
- [ ] Video generation con límites
|
| 267 |
+
- [ ] Dashboard de uso mensual
|
| 268 |
+
- [ ] Exportar influencers a CSV/JSON
|
| 269 |
+
- [ ] Integración con OnlyFans/Monetización
|
| 270 |
+
- [ ] Batch creation (crear 10 influencers de una vez)
|
Dockerfile
CHANGED
|
@@ -2,7 +2,7 @@ FROM node:20-slim
|
|
| 2 |
|
| 3 |
WORKDIR /app
|
| 4 |
|
| 5 |
-
# Install dependencies for native modules
|
| 6 |
RUN apt-get update && apt-get install -y openssl python3 make g++ && rm -rf /var/lib/apt/lists/*
|
| 7 |
|
| 8 |
# Copy package files
|
|
@@ -20,18 +20,13 @@ RUN npx prisma generate
|
|
| 20 |
# Build the application
|
| 21 |
RUN npm run build
|
| 22 |
|
| 23 |
-
# Create data directory
|
| 24 |
-
RUN mkdir -p /app/data && chmod 777 /app/data
|
| 25 |
-
|
| 26 |
# Environment variables
|
| 27 |
ENV NODE_ENV=production
|
| 28 |
ENV PORT=7860
|
| 29 |
ENV HOST=0.0.0.0
|
| 30 |
-
# Set a default SQLite path if DATABASE_URL is not provided at runtime
|
| 31 |
-
ENV DATABASE_URL="file:/app/data/sofia.db"
|
| 32 |
|
| 33 |
EXPOSE 7860
|
| 34 |
|
| 35 |
-
# Use entrypoint to handle migrations
|
| 36 |
ENTRYPOINT ["sh", "/app/entrypoint.sh"]
|
| 37 |
-
CMD ["
|
|
|
|
| 2 |
|
| 3 |
WORKDIR /app
|
| 4 |
|
| 5 |
+
# Install dependencies for Prisma and native modules
|
| 6 |
RUN apt-get update && apt-get install -y openssl python3 make g++ && rm -rf /var/lib/apt/lists/*
|
| 7 |
|
| 8 |
# Copy package files
|
|
|
|
| 20 |
# Build the application
|
| 21 |
RUN npm run build
|
| 22 |
|
|
|
|
|
|
|
|
|
|
| 23 |
# Environment variables
|
| 24 |
ENV NODE_ENV=production
|
| 25 |
ENV PORT=7860
|
| 26 |
ENV HOST=0.0.0.0
|
|
|
|
|
|
|
| 27 |
|
| 28 |
EXPOSE 7860
|
| 29 |
|
| 30 |
+
# Use entrypoint to handle migrations
|
| 31 |
ENTRYPOINT ["sh", "/app/entrypoint.sh"]
|
| 32 |
+
CMD ["node", ".next/standalone/server.js"]
|
MIGRATION_GUIDE.md
ADDED
|
@@ -0,0 +1,257 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# 🗂️ Guía de Migración de Base de Datos - Stripe Integration
|
| 2 |
+
|
| 3 |
+
## 📋 Descripción General
|
| 4 |
+
|
| 5 |
+
Este proyecto ha sido actualizado con:
|
| 6 |
+
- **Integración Stripe**: Pagos, checkout, webhooks
|
| 7 |
+
- **Renovación Automática**: Suscripciones recurrentes mensuales
|
| 8 |
+
- **Contabilidad Exacta**: Modelo `Earning` para cada pago
|
| 9 |
+
- **Cancelación Cliente**: Cancelar suscripciones cuando quieran
|
| 10 |
+
|
| 11 |
+
Para esto, necesitas ejecutar una **migración de BD** que agregue campos nuevos a las tablas.
|
| 12 |
+
|
| 13 |
+
---
|
| 14 |
+
|
| 15 |
+
## ⚙️ Pre-requisitos
|
| 16 |
+
|
| 17 |
+
✅ Tienes PostgreSQL o SQLite instalado y funcionando
|
| 18 |
+
✅ Tu `.env` tiene `DATABASE_URL` configurado
|
| 19 |
+
✅ Ejecutaste `npm install stripe` (ya hecho)
|
| 20 |
+
✅ Node.js 18+ instalado
|
| 21 |
+
|
| 22 |
+
---
|
| 23 |
+
|
| 24 |
+
## 🚀 Paso 1: Ejecutar la Migración
|
| 25 |
+
|
| 26 |
+
Abre la terminal en la raíz del proyecto:
|
| 27 |
+
|
| 28 |
+
```bash
|
| 29 |
+
cd c:\Users\gemag\sofia-cloud
|
| 30 |
+
npx prisma migrate dev --name add_stripe_and_renewals
|
| 31 |
+
```
|
| 32 |
+
|
| 33 |
+
**¿Qué pasa?**
|
| 34 |
+
1. Prisma detecta cambios en `prisma/schema.prisma` (campos Stripe nuevos)
|
| 35 |
+
2. Genera un archivo de migración en `prisma/migrations/`
|
| 36 |
+
3. **Aplica automáticamente** la migración a tu BD local
|
| 37 |
+
4. Regenera el cliente Prisma
|
| 38 |
+
|
| 39 |
+
**Salida esperada:**
|
| 40 |
+
```
|
| 41 |
+
✓ Created new migration: ./prisma/migrations/202501xx_add_stripe_and_renewals/migration.sql
|
| 42 |
+
✓ Database has been updated
|
| 43 |
+
✓ Generated Prisma Client
|
| 44 |
+
```
|
| 45 |
+
|
| 46 |
+
**Si hay error:**
|
| 47 |
+
```bash
|
| 48 |
+
# Revertir si algo sale mal:
|
| 49 |
+
npx prisma migrate resolve --rolled-back <nombre-migracion>
|
| 50 |
+
```
|
| 51 |
+
|
| 52 |
+
---
|
| 53 |
+
|
| 54 |
+
## ✅ Paso 2: Verificar la Migración
|
| 55 |
+
|
| 56 |
+
Confirma que los campos nuevos existen en la BD:
|
| 57 |
+
|
| 58 |
+
```bash
|
| 59 |
+
# Ver estado de migraciones
|
| 60 |
+
npx prisma migrate status
|
| 61 |
+
|
| 62 |
+
# Ver el contenido de la BD
|
| 63 |
+
npx prisma studio
|
| 64 |
+
```
|
| 65 |
+
|
| 66 |
+
En Prisma Studio deberías ver en la tabla `InfluencerSubscription`:
|
| 67 |
+
- ✅ `stripeSubscriptionId` (string, opcional)
|
| 68 |
+
- ✅ `stripeCustomerId` (string, opcional)
|
| 69 |
+
- ✅ `nextRenewalDate` (DateTime)
|
| 70 |
+
- ✅ `autoRenew` (boolean, default: true)
|
| 71 |
+
|
| 72 |
+
Y en tabla `Earning`:
|
| 73 |
+
- ✅ `subscriptionId` (foreign key)
|
| 74 |
+
- ✅ `stripePaymentIntentId` (string, opcional)
|
| 75 |
+
- ✅ `isRecurring` (boolean, default: false)
|
| 76 |
+
- ✅ `paymentStatus` (enum: pending, completed, failed)
|
| 77 |
+
- ✅ `paymentMethod` (string, opcional)
|
| 78 |
+
|
| 79 |
+
---
|
| 80 |
+
|
| 81 |
+
## 🔑 Paso 3: Configurar Variables de Entorno
|
| 82 |
+
|
| 83 |
+
Edita tu `.env` (o `.env.local`) y agrega:
|
| 84 |
+
|
| 85 |
+
```env
|
| 86 |
+
# ====== STRIPE KEYS ======
|
| 87 |
+
# Obtén estos de: https://dashboard.stripe.com/apikeys
|
| 88 |
+
STRIPE_SECRET_KEY="sk_test_XXXXX..." # Para testing
|
| 89 |
+
# En producción: sk_live_XXXXX...
|
| 90 |
+
|
| 91 |
+
STRIPE_WEBHOOK_SECRET="whsec_XXXXX..." # De https://dashboard.stripe.com/webhooks
|
| 92 |
+
```
|
| 93 |
+
|
| 94 |
+
### 📌 Cómo obtener las keys:
|
| 95 |
+
|
| 96 |
+
1. **STRIPE_SECRET_KEY**
|
| 97 |
+
- Ve a [dashboard.stripe.com/apikeys](https://dashboard.stripe.com/apikeys)
|
| 98 |
+
- Mira en la sección "Secret Key"
|
| 99 |
+
- Copia la que empiece con `sk_test_` (para desarrollo)
|
| 100 |
+
|
| 101 |
+
2. **STRIPE_WEBHOOK_SECRET**
|
| 102 |
+
- Ve a [dashboard.stripe.com/webhooks](https://dashboard.stripe.com/webhooks)
|
| 103 |
+
- Haz click en "+ Add endpoint"
|
| 104 |
+
- **Endpoint URL**: `http://localhost:3000/api/payments/webhook` (local)
|
| 105 |
+
- **Eventos**: Selecciona estos:
|
| 106 |
+
- `checkout.session.completed`
|
| 107 |
+
- `customer.subscription.created`
|
| 108 |
+
- `customer.subscription.deleted`
|
| 109 |
+
- `customer.subscription.updated`
|
| 110 |
+
- `invoice.payment_succeeded`
|
| 111 |
+
- `invoice.payment_failed`
|
| 112 |
+
- Click "Add endpoint"
|
| 113 |
+
- Verás el "Signing secret" (empieza con `whsec_`)
|
| 114 |
+
- Cópialo y agrégalo a `.env`
|
| 115 |
+
|
| 116 |
+
---
|
| 117 |
+
|
| 118 |
+
## 🧪 Paso 4: Verificar la Instalación (Local)
|
| 119 |
+
|
| 120 |
+
Inicia el servidor:
|
| 121 |
+
|
| 122 |
+
```bash
|
| 123 |
+
npm run dev
|
| 124 |
+
```
|
| 125 |
+
|
| 126 |
+
Prueba que los endpoints nuevos funcionan:
|
| 127 |
+
|
| 128 |
+
```bash
|
| 129 |
+
# 1. Ver si puedes llegar al checkout
|
| 130 |
+
curl http://localhost:3000/api/payments/checkout
|
| 131 |
+
|
| 132 |
+
# 2. Ver si el webhook está listo
|
| 133 |
+
curl http://localhost:3000/api/payments/webhook
|
| 134 |
+
|
| 135 |
+
# Resultado esperado: 405 Method Not Allowed (porque es POST, no GET)
|
| 136 |
+
# ✅ Eso significa que el endpoint existe
|
| 137 |
+
```
|
| 138 |
+
|
| 139 |
+
---
|
| 140 |
+
|
| 141 |
+
## 📤 Paso 5: Para Producción (VPS/Hosting)
|
| 142 |
+
|
| 143 |
+
Cuando subes a un VPS o servidor:
|
| 144 |
+
|
| 145 |
+
```bash
|
| 146 |
+
# 1. Subir código (git push)
|
| 147 |
+
git add .
|
| 148 |
+
git commit -m "Add Stripe integration and auto-renewal"
|
| 149 |
+
git push origin main
|
| 150 |
+
|
| 151 |
+
# 2. En el servidor VPS:
|
| 152 |
+
cd /ruta/del/proyecto
|
| 153 |
+
npm install stripe # Instala Stripe
|
| 154 |
+
npx prisma migrate deploy # Aplica migraciones sin preguntar
|
| 155 |
+
|
| 156 |
+
# 3. Actualizar .env en producción:
|
| 157 |
+
# - STRIPE_SECRET_KEY: sk_live_XXXXX... (clave VIVA, no test)
|
| 158 |
+
# - STRIPE_WEBHOOK_SECRET: del webhook en producción
|
| 159 |
+
# - DATABASE_URL: URL de BD producción
|
| 160 |
+
|
| 161 |
+
npm run build
|
| 162 |
+
npm start
|
| 163 |
+
```
|
| 164 |
+
|
| 165 |
+
---
|
| 166 |
+
|
| 167 |
+
## 📊 Campos Nuevos en la BD
|
| 168 |
+
|
| 169 |
+
### InfluencerSubscription
|
| 170 |
+
|
| 171 |
+
| Campo | Tipo | Descripción |
|
| 172 |
+
|-------|------|------------|
|
| 173 |
+
| `stripeSubscriptionId` | string | ID de suscripción en Stripe (ej: sub_12345) |
|
| 174 |
+
| `stripeCustomerId` | string | ID de cliente en Stripe (ej: cus_XXXXX) |
|
| 175 |
+
| `nextRenewalDate` | DateTime | Fecha próxima renovación (calculada cada pago) |
|
| 176 |
+
| `autoRenew` | boolean | Si es true, se renueva automáticamente |
|
| 177 |
+
|
| 178 |
+
### Earning
|
| 179 |
+
|
| 180 |
+
| Campo | Tipo | Descripción |
|
| 181 |
+
|-------|------|------------|
|
| 182 |
+
| `subscriptionId` | string | FK a InfluencerSubscription |
|
| 183 |
+
| `stripePaymentIntentId` | string | ID de pago en Stripe |
|
| 184 |
+
| `isRecurring` | boolean | true si viene de renovación automática |
|
| 185 |
+
| `paymentStatus` | enum | "pending" \| "completed" \| "failed" |
|
| 186 |
+
| `paymentMethod` | string | "stripe" \| "manual" \| etc |
|
| 187 |
+
|
| 188 |
+
---
|
| 189 |
+
|
| 190 |
+
## 🔍 Verificación Post-Migración
|
| 191 |
+
|
| 192 |
+
Ejecuta esto para confirmar todo está bien:
|
| 193 |
+
|
| 194 |
+
```bash
|
| 195 |
+
# 1. Ver migrations aplicadas
|
| 196 |
+
npx prisma migrate status
|
| 197 |
+
|
| 198 |
+
# 2. Ver esquema generado
|
| 199 |
+
npx prisma generate
|
| 200 |
+
|
| 201 |
+
# 3. Compilar proyecto
|
| 202 |
+
npm run build
|
| 203 |
+
|
| 204 |
+
# Si los 3 pasos pasan sin ERROR ✅, estás listo
|
| 205 |
+
```
|
| 206 |
+
|
| 207 |
+
---
|
| 208 |
+
|
| 209 |
+
## ⚠️ Si Algo Sale Mal
|
| 210 |
+
|
| 211 |
+
### Error: "Column does not exist"
|
| 212 |
+
```bash
|
| 213 |
+
# La migración se aplicó solo parcialmente
|
| 214 |
+
# Revertir y intentar de novo:
|
| 215 |
+
npx prisma migrate resolve --rolled-back add_stripe_and_renewals
|
| 216 |
+
npx prisma migrate dev --name add_stripe_and_renewals
|
| 217 |
+
```
|
| 218 |
+
|
| 219 |
+
### Error: "STRIPE_SECRET_KEY not set"
|
| 220 |
+
```bash
|
| 221 |
+
# Olvidaste agregar la key a .env
|
| 222 |
+
# Agrega: STRIPE_SECRET_KEY="sk_test_XXXXX"
|
| 223 |
+
# Reinicia el servidor: npm run dev
|
| 224 |
+
```
|
| 225 |
+
|
| 226 |
+
### Error: "Cannot find module 'stripe'"
|
| 227 |
+
```bash
|
| 228 |
+
# Stripe no está instalado
|
| 229 |
+
npm install stripe
|
| 230 |
+
npx prisma generate
|
| 231 |
+
npm run dev
|
| 232 |
+
```
|
| 233 |
+
|
| 234 |
+
---
|
| 235 |
+
|
| 236 |
+
## 📞 Resumen de Pasos (Quick Reference)
|
| 237 |
+
|
| 238 |
+
| # | Paso | Comando | Resultado |
|
| 239 |
+
|---|------|---------|-----------|
|
| 240 |
+
| 1 | Migrar BD | `npx prisma migrate dev --name add_stripe_and_renewals` | ✅ Campos nuevos en tablas |
|
| 241 |
+
| 2 | Verificar | `npx prisma studio` | ✅ Ver nuevos campos |
|
| 242 |
+
| 3 | Config env | Editar `.env` | ✅ Keys Stripe agregadas |
|
| 243 |
+
| 4 | Webhook Stripe | Agregar endpoint en dashboard | ✅ Webhooks conectados |
|
| 244 |
+
| 5 | Compilar | `npm run build` | ✅ Sin errores |
|
| 245 |
+
| 6 | Testear | `npm run dev` + curl | ✅ Endpoints funcionan |
|
| 246 |
+
|
| 247 |
+
---
|
| 248 |
+
|
| 249 |
+
## ✨ ¡Listo! Ahora puedes:
|
| 250 |
+
|
| 251 |
+
✅ Crear suscripciones en Stripe
|
| 252 |
+
✅ Procesar pagos automáticos mensuales
|
| 253 |
+
✅ Rastrear earnings por pago exacto
|
| 254 |
+
✅ Cancelar suscripciones cuando el cliente quiera
|
| 255 |
+
✅ Ver reportes con contabilidad exacta
|
| 256 |
+
|
| 257 |
+
**¿Preguntas? Ejecuta los pasos en orden y reporta cualquier error.**
|
STRIPE_SETUP_SUMMARY.md
ADDED
|
@@ -0,0 +1,245 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# 🚀 Stripe Integration - Resumen Rápido
|
| 2 |
+
|
| 3 |
+
## ¿Qué Se Agregó?
|
| 4 |
+
|
| 5 |
+
### ✨ Nuevas Funcionalidades
|
| 6 |
+
1. **Pagos con Stripe** 💳
|
| 7 |
+
- Checkout sessions
|
| 8 |
+
- Validación de webhooks
|
| 9 |
+
- Webhook handlers completos
|
| 10 |
+
|
| 11 |
+
2. **Renovación Automática** 🔄
|
| 12 |
+
- Mensual con fecha exacta
|
| 13 |
+
- Sincronizado con Stripe
|
| 14 |
+
- Rastreo en `nextRenewalDate`
|
| 15 |
+
|
| 16 |
+
3. **Cancelación del Cliente** ❌
|
| 17 |
+
- Cancelar suscripción en cualquier momento
|
| 18 |
+
- Sincroniza con Stripe automáticamente
|
| 19 |
+
- Responde en segundos
|
| 20 |
+
|
| 21 |
+
4. **Reportes Exactos** 📊
|
| 22 |
+
- Por influencer
|
| 23 |
+
- Contabilidad por pago (`Earning` model)
|
| 24 |
+
- Estados: active, paused, expired, cancelled
|
| 25 |
+
|
| 26 |
+
### 📂 Archivos Nuevos
|
| 27 |
+
- `src/lib/stripe.ts` - Funciones helper Stripe
|
| 28 |
+
- `src/app/api/payments/checkout/route.ts` - Generar sesión checkout
|
| 29 |
+
- `src/app/api/payments/webhook/route.ts` - Recibir webhooks Stripe
|
| 30 |
+
- `src/app/api/influencers/subscription/cancel/route.ts` - Cancelar suscripción
|
| 31 |
+
|
| 32 |
+
### 📝 Archivos Actualizados
|
| 33 |
+
- `prisma/schema.prisma` - Nuevos campos Stripe+Earning
|
| 34 |
+
- `src/app/api/influencers/subscription/route.ts` - Soporta renovación
|
| 35 |
+
- `src/app/api/influencers/report/route.ts` - Reportes detallados
|
| 36 |
+
|
| 37 |
+
---
|
| 38 |
+
|
| 39 |
+
## ⚡ Quick Start
|
| 40 |
+
|
| 41 |
+
### 1. Migrar BD
|
| 42 |
+
```bash
|
| 43 |
+
npx prisma migrate dev --name add_stripe_and_renewals
|
| 44 |
+
```
|
| 45 |
+
|
| 46 |
+
### 2. Configurar .env
|
| 47 |
+
```env
|
| 48 |
+
STRIPE_SECRET_KEY="sk_test_..."
|
| 49 |
+
STRIPE_WEBHOOK_SECRET="whsec_..."
|
| 50 |
+
```
|
| 51 |
+
|
| 52 |
+
### 3. Configurar Webhook en Stripe Dashboard
|
| 53 |
+
- URL: `https://<tu-dominio>/api/payments/webhook`
|
| 54 |
+
- Eventos: checkout.session.completed, invoice.payment_*, customer.subscription.*
|
| 55 |
+
|
| 56 |
+
### 4. Compilar y Testear
|
| 57 |
+
```bash
|
| 58 |
+
npm run build # Compilar
|
| 59 |
+
npm run dev # Iniciar servidor
|
| 60 |
+
|
| 61 |
+
# En otra terminal:
|
| 62 |
+
stripe listen --forward-to localhost:3000/api/payments/webhook
|
| 63 |
+
stripe trigger checkout.session.completed
|
| 64 |
+
```
|
| 65 |
+
|
| 66 |
+
---
|
| 67 |
+
|
| 68 |
+
## 🔑 Endpoints (API)
|
| 69 |
+
|
| 70 |
+
### Crear Suscripción
|
| 71 |
+
```bash
|
| 72 |
+
POST /api/influencers/subscription
|
| 73 |
+
Content-Type: application/json
|
| 74 |
+
|
| 75 |
+
{
|
| 76 |
+
"userId": "user123",
|
| 77 |
+
"influencerId": "inf456",
|
| 78 |
+
"tier": "premium",
|
| 79 |
+
"price": 9.99
|
| 80 |
+
}
|
| 81 |
+
```
|
| 82 |
+
|
| 83 |
+
### Iniciar Pago (Checkout)
|
| 84 |
+
```bash
|
| 85 |
+
POST /api/payments/checkout
|
| 86 |
+
Content-Type: application/json
|
| 87 |
+
|
| 88 |
+
{
|
| 89 |
+
"userId": "user123",
|
| 90 |
+
"influencerId": "inf456",
|
| 91 |
+
"tier": "premium",
|
| 92 |
+
"price": 9.99
|
| 93 |
+
}
|
| 94 |
+
|
| 95 |
+
Retorna: { sessionId, checkoutUrl }
|
| 96 |
+
```
|
| 97 |
+
|
| 98 |
+
### Cancelar Suscripción
|
| 99 |
+
```bash
|
| 100 |
+
POST /api/influencers/subscription/cancel
|
| 101 |
+
Content-Type: application/json
|
| 102 |
+
|
| 103 |
+
{
|
| 104 |
+
"subscriptionId": "sub_12345",
|
| 105 |
+
"userId": "user123"
|
| 106 |
+
}
|
| 107 |
+
```
|
| 108 |
+
|
| 109 |
+
### Obtener Reportes
|
| 110 |
+
```bash
|
| 111 |
+
GET /api/influencers/report?influencerId=inf456&userId=user123
|
| 112 |
+
|
| 113 |
+
Retorna:
|
| 114 |
+
{
|
| 115 |
+
"subscribers": { active, paused, expired, cancelled },
|
| 116 |
+
"revenue": { completed, pending, failed },
|
| 117 |
+
"subscriptions": [...],
|
| 118 |
+
"payments": [...]
|
| 119 |
+
}
|
| 120 |
+
```
|
| 121 |
+
|
| 122 |
+
---
|
| 123 |
+
|
| 124 |
+
## 📚 Documentación Completa
|
| 125 |
+
|
| 126 |
+
- **[MIGRATION_GUIDE.md](./MIGRATION_GUIDE.md)** - Paso a paso migración BD + env
|
| 127 |
+
- **[STRIPE_TESTING_GUIDE.md](./STRIPE_TESTING_GUIDE.md)** - Testear con Stripe CLI
|
| 128 |
+
- **[VERIFICATION_CHECKLIST.md](./VERIFICATION_CHECKLIST.md)** - Pre-producción checklist
|
| 129 |
+
|
| 130 |
+
---
|
| 131 |
+
|
| 132 |
+
## 🔒 Seguridad
|
| 133 |
+
|
| 134 |
+
✅ Validación de firmas Stripe (HMAC-SHA256)
|
| 135 |
+
✅ Campos sensibles en `.env`, no en código
|
| 136 |
+
✅ `.gitignore` excluye `.env`
|
| 137 |
+
✅ Webhook endpoint validado
|
| 138 |
+
|
| 139 |
+
---
|
| 140 |
+
|
| 141 |
+
## 📊 Diagrama de Flujo
|
| 142 |
+
|
| 143 |
+
```
|
| 144 |
+
Cliente
|
| 145 |
+
↓
|
| 146 |
+
POST /api/payments/checkout
|
| 147 |
+
↓
|
| 148 |
+
Retorna checkoutUrl
|
| 149 |
+
↓
|
| 150 |
+
Cliente redirige a Stripe
|
| 151 |
+
↓
|
| 152 |
+
Cliente paga tarjeta
|
| 153 |
+
↓
|
| 154 |
+
Stripe → Webhook checkout.session.completed
|
| 155 |
+
↓
|
| 156 |
+
Server: Crea InfluencerSubscription + Earning
|
| 157 |
+
↓
|
| 158 |
+
Cliente: Suscripción activa
|
| 159 |
+
↓
|
| 160 |
+
(30 días después)
|
| 161 |
+
↓
|
| 162 |
+
Stripe: invoice.payment_succeeded
|
| 163 |
+
↓
|
| 164 |
+
Server: Crea nuevo Earning + actualiza nextRenewalDate
|
| 165 |
+
↓
|
| 166 |
+
Cliente: Renovación automática
|
| 167 |
+
```
|
| 168 |
+
|
| 169 |
+
---
|
| 170 |
+
|
| 171 |
+
## ⚙️ Configuración BD
|
| 172 |
+
|
| 173 |
+
### InfluencerSubscription (Nueva: Stripe Fields)
|
| 174 |
+
```sql
|
| 175 |
+
- stripeSubscriptionId TEXT
|
| 176 |
+
- stripeCustomerId TEXT
|
| 177 |
+
- nextRenewalDate DATETIME ← Próxima renovación
|
| 178 |
+
- autoRenew BOOLEAN ← true = se renueva automático
|
| 179 |
+
```
|
| 180 |
+
|
| 181 |
+
### Earning (Mejorado)
|
| 182 |
+
```sql
|
| 183 |
+
- subscriptionId TEXT (FK)
|
| 184 |
+
- stripePaymentIntentId TEXT
|
| 185 |
+
- isRecurring BOOLEAN ← true = pago automático
|
| 186 |
+
- paymentStatus ENUM ← pending|completed|failed
|
| 187 |
+
- paymentMethod TEXT ← "stripe" | "manual"
|
| 188 |
+
```
|
| 189 |
+
|
| 190 |
+
---
|
| 191 |
+
|
| 192 |
+
## 🧪 Testing Local
|
| 193 |
+
|
| 194 |
+
```bash
|
| 195 |
+
# Terminal 1: Webhook listener
|
| 196 |
+
stripe listen --forward-to localhost:3000/api/payments/webhook
|
| 197 |
+
|
| 198 |
+
# Terminal 2: Servidor
|
| 199 |
+
npm run dev
|
| 200 |
+
|
| 201 |
+
# Terminal 3: Simular evento
|
| 202 |
+
stripe trigger checkout.session.completed
|
| 203 |
+
|
| 204 |
+
# Ver BD:
|
| 205 |
+
npx prisma studio
|
| 206 |
+
```
|
| 207 |
+
|
| 208 |
+
---
|
| 209 |
+
|
| 210 |
+
## 🚀 Deployment
|
| 211 |
+
|
| 212 |
+
```bash
|
| 213 |
+
# VPS/Producción
|
| 214 |
+
git push origin main
|
| 215 |
+
|
| 216 |
+
# En VPS:
|
| 217 |
+
npm install stripe
|
| 218 |
+
npx prisma migrate deploy # Aplicar migraciones
|
| 219 |
+
npm run build
|
| 220 |
+
npm start
|
| 221 |
+
|
| 222 |
+
# Actualizar .env con:
|
| 223 |
+
# - STRIPE_SECRET_KEY=sk_live_... (VIVA, no test)
|
| 224 |
+
# - STRIPE_WEBHOOK_SECRET=whsec_... (del webhook en producción)
|
| 225 |
+
# - DATABASE_URL=<BD producción>
|
| 226 |
+
```
|
| 227 |
+
|
| 228 |
+
---
|
| 229 |
+
|
| 230 |
+
## ✅ Status
|
| 231 |
+
|
| 232 |
+
| Componente | Status |
|
| 233 |
+
|-----------|--------|
|
| 234 |
+
| Stripe SDK | ✅ Instalado |
|
| 235 |
+
| BD Schema | ✅ Actualizado |
|
| 236 |
+
| Endpoints | ✅ Implementados |
|
| 237 |
+
| Webhooks | ✅ Validados |
|
| 238 |
+
| Renovación | ✅ Automática |
|
| 239 |
+
| Compilación | ✅ OK (28.4s) |
|
| 240 |
+
| Testing | 📖 Ver STRIPE_TESTING_GUIDE.md |
|
| 241 |
+
| BD Migration | 📖 Ver MIGRATION_GUIDE.md |
|
| 242 |
+
|
| 243 |
+
---
|
| 244 |
+
|
| 245 |
+
**¿Listo?** Sigue los pasos en [MIGRATION_GUIDE.md](./MIGRATION_GUIDE.md) 🎯
|
STRIPE_TESTING_GUIDE.md
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# 🧪 Guía de Testing Local con Stripe CLI
|
| 2 |
+
|
| 3 |
+
## Descripción
|
| 4 |
+
|
| 5 |
+
Usa **Stripe CLI** para probar webhooks localmente sin necesidad de deploy a producción.
|
| 6 |
+
|
| 7 |
+
---
|
| 8 |
+
|
| 9 |
+
## 1️⃣ Instalar Stripe CLI
|
| 10 |
+
|
| 11 |
+
### Windows (con Chocolatey):
|
| 12 |
+
```bash
|
| 13 |
+
choco install stripe-cli
|
| 14 |
+
```
|
| 15 |
+
|
| 16 |
+
### Windows (Manual):
|
| 17 |
+
1. Descarga de: https://github.com/stripe/stripe-cli/releases
|
| 18 |
+
2. Descomprime en `C:\Program Files\stripe-cli`
|
| 19 |
+
3. Agrega a PATH (o usa ruta completa)
|
| 20 |
+
|
| 21 |
+
### Verificar instalación:
|
| 22 |
+
```bash
|
| 23 |
+
stripe --version
|
| 24 |
+
# Salida esperada: v1.x.x
|
| 25 |
+
```
|
| 26 |
+
|
| 27 |
+
---
|
| 28 |
+
|
| 29 |
+
## 2️⃣ Login con Stripe
|
| 30 |
+
|
| 31 |
+
```bash
|
| 32 |
+
stripe login
|
| 33 |
+
```
|
| 34 |
+
|
| 35 |
+
Te abrirá un navegador para autorizar. Haz click "Allow" y vuelve a la terminal.
|
| 36 |
+
|
| 37 |
+
Done ✅ cuando veas:
|
| 38 |
+
```
|
| 39 |
+
✓ Authenticated with Stripe
|
| 40 |
+
```
|
| 41 |
+
|
| 42 |
+
---
|
| 43 |
+
|
| 44 |
+
## 3️⃣ Configurar Webhook Local
|
| 45 |
+
|
| 46 |
+
En una terminal, ejecuta:
|
| 47 |
+
|
| 48 |
+
```bash
|
| 49 |
+
stripe listen --forward-to localhost:3000/api/payments/webhook
|
| 50 |
+
```
|
| 51 |
+
|
| 52 |
+
**Salida:**
|
| 53 |
+
```
|
| 54 |
+
Getting ready to listen for live events...
|
| 55 |
+
Ready! You are now listening for Stripe events.
|
| 56 |
+
|
| 57 |
+
Webhook signing secret for whsec_XXXXX
|
| 58 |
+
```
|
| 59 |
+
|
| 60 |
+
**IMPORTANTE**: Copia ese `whsec_XXXXX` y actualiza tu `.env`:
|
| 61 |
+
|
| 62 |
+
```env
|
| 63 |
+
STRIPE_WEBHOOK_SECRET="whsec_XXXXX"
|
| 64 |
+
```
|
| 65 |
+
|
| 66 |
+
---
|
| 67 |
+
|
| 68 |
+
## 4️⃣ Iniciar Servidor (otra terminal)
|
| 69 |
+
|
| 70 |
+
```bash
|
| 71 |
+
npm run dev
|
| 72 |
+
# Server running at localhost:3000
|
| 73 |
+
```
|
| 74 |
+
|
| 75 |
+
---
|
| 76 |
+
|
| 77 |
+
## 5️⃣ Crear un Evento de Prueba
|
| 78 |
+
|
| 79 |
+
En una **tercera terminal**, simula un evento:
|
| 80 |
+
|
| 81 |
+
### Test: Checkout Completado
|
| 82 |
+
```bash
|
| 83 |
+
stripe trigger checkout.session.completed
|
| 84 |
+
```
|
| 85 |
+
|
| 86 |
+
**Verás en la consola del servidor:**
|
| 87 |
+
```
|
| 88 |
+
✓ [Webhook] Received checkout.session.completed
|
| 89 |
+
✓ Created subscription in DB
|
| 90 |
+
```
|
| 91 |
+
|
| 92 |
+
### Test: Pago de Factura (Renovación)
|
| 93 |
+
```bash
|
| 94 |
+
stripe trigger invoice.payment_succeeded
|
| 95 |
+
```
|
| 96 |
+
|
| 97 |
+
### Test: Pago Fallido
|
| 98 |
+
```bash
|
| 99 |
+
stripe trigger invoice.payment_failed
|
| 100 |
+
```
|
| 101 |
+
|
| 102 |
+
---
|
| 103 |
+
|
| 104 |
+
## 6️⃣ Verificar Eventos
|
| 105 |
+
|
| 106 |
+
En la terminal donde ejecutaste `stripe listen`:
|
| 107 |
+
|
| 108 |
+
```
|
| 109 |
+
2025-01-15 10:30:00 → checkout.session.completed
|
| 110 |
+
2025-01-15 10:30:01 → invoice.payment_succeeded
|
| 111 |
+
```
|
| 112 |
+
|
| 113 |
+
---
|
| 114 |
+
|
| 115 |
+
## 🔍 Debugging
|
| 116 |
+
|
| 117 |
+
### Ver logs del webhook:
|
| 118 |
+
```bash
|
| 119 |
+
stripe logs tail
|
| 120 |
+
```
|
| 121 |
+
|
| 122 |
+
### Ver eventos recientes en dashboard:
|
| 123 |
+
https://dashboard.stripe.com/test/webhooks
|
| 124 |
+
|
| 125 |
+
---
|
| 126 |
+
|
| 127 |
+
## ✅ Test Flow Completo
|
| 128 |
+
|
| 129 |
+
```bash
|
| 130 |
+
# Terminal 1: Webhook listener
|
| 131 |
+
stripe listen --forward-to localhost:3000/api/payments/webhook
|
| 132 |
+
|
| 133 |
+
# Terminal 2: Servidor
|
| 134 |
+
npm run dev
|
| 135 |
+
|
| 136 |
+
# Terminal 3: Triggear eventos
|
| 137 |
+
stripe trigger checkout.session.completed
|
| 138 |
+
stripe trigger invoice.payment_succeeded
|
| 139 |
+
|
| 140 |
+
# Ver resultados en BD:
|
| 141 |
+
npx prisma studio
|
| 142 |
+
```
|
| 143 |
+
|
| 144 |
+
---
|
| 145 |
+
|
| 146 |
+
## 💡 Tips
|
| 147 |
+
|
| 148 |
+
- **Stripe CLI** se desconecta automáticamente cada 1 hora
|
| 149 |
+
- Ejecuta nuevamente: `stripe listen --forward-to ...`
|
| 150 |
+
- Los eventos triggados son **reales** (van a tu BD)
|
| 151 |
+
- No requiere tarjeta de crédito
|
| 152 |
+
- Los webhooks se reciben en **tiempo real**
|
| 153 |
+
|
| 154 |
+
---
|
| 155 |
+
|
| 156 |
+
**¡Listo para testear!** 🚀
|
VERIFICATION_CHECKLIST.md
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# ✅ Checklist de Verificación Pre-Producción
|
| 2 |
+
|
| 3 |
+
## 📋 Antes de Desplegar a Producción
|
| 4 |
+
|
| 5 |
+
Marca cada item conforme lo completess:
|
| 6 |
+
|
| 7 |
+
### 1️⃣ Base de Datos
|
| 8 |
+
- [ ] Ejecuté: `npx prisma migrate dev --name add_stripe_and_renewals`
|
| 9 |
+
- [ ] Verifiqué con: `npx prisma studio` (veo campos Stripe)
|
| 10 |
+
- [ ] BD migrada sin errores
|
| 11 |
+
|
| 12 |
+
### 2️⃣ Variables de Entorno
|
| 13 |
+
- [ ] `.env` tiene `STRIPE_SECRET_KEY` (sk_test_...)
|
| 14 |
+
- [ ] `.env` tiene `STRIPE_WEBHOOK_SECRET` (whsec_...)
|
| 15 |
+
- [ ] `.env` tiene `DATABASE_URL` válida
|
| 16 |
+
- [ ] Probé local: `npm run dev` inicia sin errors
|
| 17 |
+
|
| 18 |
+
### 3️⃣ Compilación
|
| 19 |
+
- [ ] Ejecuté: `npm run build`
|
| 20 |
+
- [ ] Resultado: "✓ Compiled successfully"
|
| 21 |
+
- [ ] Cero errores de TypeScript
|
| 22 |
+
|
| 23 |
+
### 4️⃣ Testing Local
|
| 24 |
+
- [ ] Instalé Stripe CLI: `stripe --version`
|
| 25 |
+
- [ ] Ejecuté: `stripe listen --forward-to localhost:3000/api/payments/webhook`
|
| 26 |
+
- [ ] Obtenía webhook signing secret (whsec_...)
|
| 27 |
+
- [ ] Inicié servidor: `npm run dev`
|
| 28 |
+
- [ ] Triggee evento: `stripe trigger checkout.session.completed`
|
| 29 |
+
- [ ] Vi en consola: "✓ [Webhook] Received checkout.session.completed"
|
| 30 |
+
|
| 31 |
+
### 5️⃣ Endpoints Funcionan
|
| 32 |
+
- [ ] `POST /api/influencers/subscription` - Crear suscripción ✅
|
| 33 |
+
- [ ] `GET /api/influencers/subscription?userId=X` - Listar ✅
|
| 34 |
+
- [ ] `POST /api/payments/checkout` - Generar session ✅
|
| 35 |
+
- [ ] `POST /api/payments/webhook` - Recibir webhooks ✅
|
| 36 |
+
- [ ] `POST /api/influencers/subscription/cancel` - Cancelar ✅
|
| 37 |
+
- [ ] `GET /api/influencers/report?influencerId=X` - Reportes ✅
|
| 38 |
+
|
| 39 |
+
### 6️⃣ Configuración Stripe Dashboard
|
| 40 |
+
- [ ] Fui a [dashboard.stripe.com/webhooks](https://dashboard.stripe.com/webhooks)
|
| 41 |
+
- [ ] Agregué endpoint: `https://<mi-dominio>/api/payments/webhook`
|
| 42 |
+
- [ ] Seleccioné eventos:
|
| 43 |
+
- [ ] checkout.session.completed
|
| 44 |
+
- [ ] customer.subscription.created
|
| 45 |
+
- [ ] customer.subscription.deleted
|
| 46 |
+
- [ ] customer.subscription.updated
|
| 47 |
+
- [ ] invoice.payment_succeeded
|
| 48 |
+
- [ ] invoice.payment_failed
|
| 49 |
+
- [ ] Copié Signing Secret → agregué a `.env.production`
|
| 50 |
+
|
| 51 |
+
### 7️⃣ Código Revisado
|
| 52 |
+
- [ ] `src/lib/stripe.ts` tiene todas las funciones helper ✅
|
| 53 |
+
- [ ] `src/app/api/payments/webhook/route.ts` valida firma Stripe ✅
|
| 54 |
+
- [ ] `src/app/api/influencers/subscription/route.ts` crea `nextRenewalDate` ✅
|
| 55 |
+
- [ ] `src/app/api/influencers/report/route.ts` agrupa por estatus ✅
|
| 56 |
+
- [ ] `prisma/schema.prisma` tiene campos nuevos ✅
|
| 57 |
+
|
| 58 |
+
### 8️⃣ Seguridad
|
| 59 |
+
- [ ] `STRIPE_SECRET_KEY` NO está en GitHub (en `.env`, no en `.env.example`)
|
| 60 |
+
- [ ] `STRIPE_WEBHOOK_SECRET` NO está en GitHub
|
| 61 |
+
- [ ] `.env` está en `.gitignore` ✅
|
| 62 |
+
- [ ] No hay keys hardcodeadas en `src/`
|
| 63 |
+
|
| 64 |
+
### 9️⃣ Documentación
|
| 65 |
+
- [ ] Leí: `MIGRATION_GUIDE.md` ✅
|
| 66 |
+
- [ ] Leí: `STRIPE_TESTING_GUIDE.md` ✅
|
| 67 |
+
- [ ] Entiendo el flujo: Cliente → Checkout → Stripe → Webhook → BD
|
| 68 |
+
|
| 69 |
+
### 🔟 VPS/Producción
|
| 70 |
+
- [ ] URL de dominio lista: `https://<mi-dominio>`
|
| 71 |
+
- [ ] SSL/HTTPS configurado ✅
|
| 72 |
+
- [ ] PostgreSQL en producción (o SQLite respaldada) ✅
|
| 73 |
+
- [ ] `.env` en producción tiene:
|
| 74 |
+
- [ ] `STRIPE_SECRET_KEY=sk_live_XXX` (clave VIVA, no test)
|
| 75 |
+
- [ ] `STRIPE_WEBHOOK_SECRET=whsec_XXX` (del webhook en producción)
|
| 76 |
+
- [ ] `DATABASE_URL` apuntando a BD producción
|
| 77 |
+
- [ ] Ejecutaré en VPS: `npx prisma migrate deploy`
|
| 78 |
+
|
| 79 |
+
---
|
| 80 |
+
|
| 81 |
+
## 🚨 Errores Comunes
|
| 82 |
+
|
| 83 |
+
| Error | Causa | Solución |
|
| 84 |
+
|-------|-------|----------|
|
| 85 |
+
| "Invalid webhook signature" | `STRIPE_WEBHOOK_SECRET` incorrecto | Copia nuevamente de Stripe Dashboard |
|
| 86 |
+
| "Column 'stripeSubscriptionId' does not exist" | Migración no aplicada | `npx prisma migrate dev` |
|
| 87 |
+
| "Cannot find module 'stripe'" | No instaló Stripe | `npm install stripe` |
|
| 88 |
+
| "STRIPE_SECRET_KEY is undefined" | `.env` no tiene la key | Edita `.env` y agrega `STRIPE_SECRET_KEY` |
|
| 89 |
+
| Webhooks no se reciben | Endpoint no existe en Stripe Dashboard | Agrega endpoint en Webhooks settings |
|
| 90 |
+
|
| 91 |
+
---
|
| 92 |
+
|
| 93 |
+
## 📊 Flujo de Pago Esperado
|
| 94 |
+
|
| 95 |
+
```
|
| 96 |
+
1. Cliente: POST /api/payments/checkout
|
| 97 |
+
↓
|
| 98 |
+
2. Server: Genera Stripe session
|
| 99 |
+
↓
|
| 100 |
+
3. Cliente: Redirigen a checkout.stripe.com
|
| 101 |
+
↓
|
| 102 |
+
4. Cliente: Completa pago en Stripe (tarjeta, etc)
|
| 103 |
+
↓
|
| 104 |
+
5. Stripe: Envía webhook checkout.session.completed
|
| 105 |
+
↓
|
| 106 |
+
6. Server: Webhook handler crea InfluencerSubscription + Earning
|
| 107 |
+
↓
|
| 108 |
+
7. DB: Actualizado con suscripción activa
|
| 109 |
+
↓
|
| 110 |
+
8. Cliente: Recibe email de Stripe + confirmación
|
| 111 |
+
```
|
| 112 |
+
|
| 113 |
+
---
|
| 114 |
+
|
| 115 |
+
## 📞 Soporte
|
| 116 |
+
|
| 117 |
+
Si algo falla:
|
| 118 |
+
|
| 119 |
+
1. **Revisa logs**: `npm run dev` (consola local)
|
| 120 |
+
2. **Stripe Dashboard**: [test/webhooks](https://dashboard.stripe.com/test/webhooks) → ver events
|
| 121 |
+
3. **Prisma Studio**: `npx prisma studio` → verificar datos en BD
|
| 122 |
+
4. **Este checklist**: Marca qué pasos ya hiciste
|
| 123 |
+
|
| 124 |
+
---
|
| 125 |
+
|
| 126 |
+
**¡Cuando todos los items estén marcados ✅, estás listo para producción!** 🚀
|
entrypoint.sh
CHANGED
|
@@ -1,10 +1,8 @@
|
|
| 1 |
#!/bin/sh
|
| 2 |
|
| 3 |
-
#
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
cd /app && npx prisma db push --skip-generate
|
| 7 |
-
fi
|
| 8 |
|
| 9 |
# Start the application
|
| 10 |
exec "$@"
|
|
|
|
| 1 |
#!/bin/sh
|
| 2 |
|
| 3 |
+
# Run Prisma migrations against PostgreSQL
|
| 4 |
+
echo "Running database migrations..."
|
| 5 |
+
cd /app && npx prisma db push --skip-generate 2>/dev/null || echo "Migration skipped or already up to date"
|
|
|
|
|
|
|
| 6 |
|
| 7 |
# Start the application
|
| 8 |
exec "$@"
|
next.config.ts
CHANGED
|
@@ -1,7 +1,5 @@
|
|
| 1 |
import type { NextConfig } from 'next';
|
| 2 |
|
| 3 |
-
import path from 'path';
|
| 4 |
-
|
| 5 |
const nextConfig: NextConfig = {
|
| 6 |
output: 'standalone',
|
| 7 |
reactStrictMode: true,
|
|
|
|
| 1 |
import type { NextConfig } from 'next';
|
| 2 |
|
|
|
|
|
|
|
| 3 |
const nextConfig: NextConfig = {
|
| 4 |
output: 'standalone',
|
| 5 |
reactStrictMode: true,
|
package-lock.json
CHANGED
|
The diff for this file is too large to render.
See raw diff
|
|
|
package.json
CHANGED
|
@@ -56,6 +56,7 @@
|
|
| 56 |
"react-resizable-panels": "^4.6.5",
|
| 57 |
"recharts": "^3.7.0",
|
| 58 |
"sonner": "^2.0.6",
|
|
|
|
| 59 |
"tailwind-merge": "^3.3.1",
|
| 60 |
"tailwindcss-animate": "^1.0.7",
|
| 61 |
"tw-animate-css": "^1.3.5",
|
|
@@ -68,7 +69,9 @@
|
|
| 68 |
"@types/node": "25.3.2",
|
| 69 |
"@types/react": "^19",
|
| 70 |
"@types/react-dom": "^19",
|
|
|
|
|
|
|
| 71 |
"tailwindcss": "^4",
|
| 72 |
"typescript": "^5"
|
| 73 |
}
|
| 74 |
-
}
|
|
|
|
| 56 |
"react-resizable-panels": "^4.6.5",
|
| 57 |
"recharts": "^3.7.0",
|
| 58 |
"sonner": "^2.0.6",
|
| 59 |
+
"stripe": "^20.4.0",
|
| 60 |
"tailwind-merge": "^3.3.1",
|
| 61 |
"tailwindcss-animate": "^1.0.7",
|
| 62 |
"tw-animate-css": "^1.3.5",
|
|
|
|
| 69 |
"@types/node": "25.3.2",
|
| 70 |
"@types/react": "^19",
|
| 71 |
"@types/react-dom": "^19",
|
| 72 |
+
"eslint": "^10.0.2",
|
| 73 |
+
"eslint-config-next": "^16.1.6",
|
| 74 |
"tailwindcss": "^4",
|
| 75 |
"typescript": "^5"
|
| 76 |
}
|
| 77 |
+
}
|
prisma/schema.prisma
CHANGED
|
@@ -28,6 +28,7 @@ model User {
|
|
| 28 |
|
| 29 |
chatSessions ChatSession[]
|
| 30 |
assetDeliveries AssetDelivery[]
|
|
|
|
| 31 |
|
| 32 |
@@unique([externalId, source])
|
| 33 |
}
|
|
@@ -263,18 +264,24 @@ model Subscriber {
|
|
| 263 |
}
|
| 264 |
|
| 265 |
model Earning {
|
| 266 |
-
id
|
| 267 |
-
platformId
|
| 268 |
-
platform
|
| 269 |
-
|
| 270 |
-
|
| 271 |
-
|
| 272 |
-
|
| 273 |
-
|
| 274 |
-
|
| 275 |
-
|
| 276 |
-
|
| 277 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 278 |
}
|
| 279 |
|
| 280 |
// ============================================
|
|
@@ -493,6 +500,32 @@ model AIInfluencer {
|
|
| 493 |
isActive Boolean @default(true)
|
| 494 |
createdAt DateTime @default(now())
|
| 495 |
updatedAt DateTime @updatedAt
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 496 |
}
|
| 497 |
|
| 498 |
// ============================================
|
|
|
|
| 28 |
|
| 29 |
chatSessions ChatSession[]
|
| 30 |
assetDeliveries AssetDelivery[]
|
| 31 |
+
influencerSubscriptions InfluencerSubscription[]
|
| 32 |
|
| 33 |
@@unique([externalId, source])
|
| 34 |
}
|
|
|
|
| 264 |
}
|
| 265 |
|
| 266 |
model Earning {
|
| 267 |
+
id String @id @default(cuid())
|
| 268 |
+
platformId String
|
| 269 |
+
platform MonetizationPlatform @relation(fields: [platformId], references: [id])
|
| 270 |
+
subscriptionId String?
|
| 271 |
+
subscription InfluencerSubscription? @relation(fields: [subscriptionId], references: [id])
|
| 272 |
+
type String // "subscription", "post", "tip", "ppv"
|
| 273 |
+
amount Float
|
| 274 |
+
currency String @default("USD")
|
| 275 |
+
postId String?
|
| 276 |
+
subscriberId String?
|
| 277 |
+
status String @default("pending") // pending, completed, failed
|
| 278 |
+
paymentMethod String? // "card", "bank_transfer", "paypal"
|
| 279 |
+
stripePaymentIntentId String? // Para rastrear en Stripe
|
| 280 |
+
isRecurring Boolean @default(false)
|
| 281 |
+
description String?
|
| 282 |
+
processedAt DateTime?
|
| 283 |
+
metadata String?
|
| 284 |
+
createdAt DateTime @default(now())
|
| 285 |
}
|
| 286 |
|
| 287 |
// ============================================
|
|
|
|
| 500 |
isActive Boolean @default(true)
|
| 501 |
createdAt DateTime @default(now())
|
| 502 |
updatedAt DateTime @updatedAt
|
| 503 |
+
influencerSubscriptions InfluencerSubscription[]
|
| 504 |
+
}
|
| 505 |
+
|
| 506 |
+
// Suscripciones por influencer: cada cliente se suscribe por separado a cada influencer
|
| 507 |
+
model InfluencerSubscription {
|
| 508 |
+
id String @id @default(cuid())
|
| 509 |
+
influencerId String
|
| 510 |
+
influencer AIInfluencer @relation(fields: [influencerId], references: [id])
|
| 511 |
+
userId String?
|
| 512 |
+
user User? @relation(fields: [userId], references: [id])
|
| 513 |
+
tier String // tier contratado para esta suscripción (basic, premium, etc.)
|
| 514 |
+
status String @default("active") // active, paused, expired, cancelled
|
| 515 |
+
joinedAt DateTime @default(now())
|
| 516 |
+
expiresAt DateTime?
|
| 517 |
+
nextRenewalDate DateTime? // Próxima fecha de renovación automática
|
| 518 |
+
autoRenew Boolean @default(true) // Renovación automática activada
|
| 519 |
+
price Float?
|
| 520 |
+
currency String @default("USD")
|
| 521 |
+
// Integración con Stripe
|
| 522 |
+
stripeSubscriptionId String? // ID de suscripción en Stripe
|
| 523 |
+
stripeCustomerId String? // ID de cliente en Stripe
|
| 524 |
+
paymentMethodId String? // ID del método de pago en Stripe
|
| 525 |
+
metadata String?
|
| 526 |
+
earnings Earning[] // Relación con pagos registrados
|
| 527 |
+
createdAt DateTime @default(now())
|
| 528 |
+
updatedAt DateTime @updatedAt
|
| 529 |
}
|
| 530 |
|
| 531 |
// ============================================
|
src/app/api/generate/image/route.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
| 1 |
import { NextRequest, NextResponse } from "next/server";
|
| 2 |
import ZAI from "z-ai-web-dev-sdk";
|
| 3 |
import { db } from "@/lib/db";
|
|
|
|
| 4 |
|
| 5 |
const CENSOR_RULES: Record<string, string> = {
|
| 6 |
youtube: "family friendly, no nudity, no violence",
|
|
@@ -13,12 +14,18 @@ const CENSOR_RULES: Record<string, string> = {
|
|
| 13 |
export async function POST(request: NextRequest) {
|
| 14 |
try {
|
| 15 |
const body = await request.json();
|
| 16 |
-
const { prompt, optimizedPrompt, platform = "general", style = "realistic", size = "1024x1024" } = body;
|
| 17 |
|
| 18 |
if (!prompt && !optimizedPrompt) {
|
| 19 |
return NextResponse.json({ success: false, error: "Se requiere un prompt" }, { status: 400 });
|
| 20 |
}
|
| 21 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 22 |
let finalPrompt = optimizedPrompt || prompt;
|
| 23 |
const censorRule = CENSOR_RULES[platform] || CENSOR_RULES.general;
|
| 24 |
finalPrompt = finalPrompt + ", " + censorRule;
|
|
@@ -57,17 +64,26 @@ export async function POST(request: NextRequest) {
|
|
| 57 |
data: { status: "completed", filePath: "generated" }
|
| 58 |
});
|
| 59 |
|
|
|
|
|
|
|
|
|
|
| 60 |
await db.agentTask.create({
|
| 61 |
data: { type: "generate_image", status: "completed", input: prompt, output: "Imagen generada", completedAt: new Date() }
|
| 62 |
});
|
| 63 |
|
| 64 |
-
return NextResponse.json({
|
|
|
|
|
|
|
|
|
|
|
|
|
| 65 |
} catch (genError) {
|
| 66 |
await db.content.update({ where: { id: contentRecord.id }, data: { status: "failed" } });
|
| 67 |
-
|
|
|
|
| 68 |
}
|
| 69 |
-
} catch (error) {
|
| 70 |
-
|
|
|
|
| 71 |
}
|
| 72 |
}
|
| 73 |
|
|
@@ -75,7 +91,7 @@ export async function GET(request: NextRequest) {
|
|
| 75 |
try {
|
| 76 |
const { searchParams } = new URL(request.url);
|
| 77 |
const platform = searchParams.get("platform");
|
| 78 |
-
const limit = parseInt(searchParams.get("limit") || "20");
|
| 79 |
const where: Record<string, any> = { type: "image" };
|
| 80 |
if (platform) where.platform = platform;
|
| 81 |
const images = await db.content.findMany({ where, orderBy: { createdAt: "desc" }, take: limit });
|
|
|
|
| 1 |
import { NextRequest, NextResponse } from "next/server";
|
| 2 |
import ZAI from "z-ai-web-dev-sdk";
|
| 3 |
import { db } from "@/lib/db";
|
| 4 |
+
import { validateUserCredit, logResourceUsage } from "@/lib/credits";
|
| 5 |
|
| 6 |
const CENSOR_RULES: Record<string, string> = {
|
| 7 |
youtube: "family friendly, no nudity, no violence",
|
|
|
|
| 14 |
export async function POST(request: NextRequest) {
|
| 15 |
try {
|
| 16 |
const body = await request.json();
|
| 17 |
+
const { prompt, optimizedPrompt, platform = "general", style = "realistic", size = "1024x1024", userId, tier = "free" } = body;
|
| 18 |
|
| 19 |
if (!prompt && !optimizedPrompt) {
|
| 20 |
return NextResponse.json({ success: false, error: "Se requiere un prompt" }, { status: 400 });
|
| 21 |
}
|
| 22 |
|
| 23 |
+
// Validar créditos
|
| 24 |
+
const creditCheck = await validateUserCredit(db, userId || "anonymous", "images_per_month", tier);
|
| 25 |
+
if (!creditCheck.allowed) {
|
| 26 |
+
return NextResponse.json({ success: false, error: creditCheck.reason, remaining: creditCheck.remaining }, { status: 429 });
|
| 27 |
+
}
|
| 28 |
+
|
| 29 |
let finalPrompt = optimizedPrompt || prompt;
|
| 30 |
const censorRule = CENSOR_RULES[platform] || CENSOR_RULES.general;
|
| 31 |
finalPrompt = finalPrompt + ", " + censorRule;
|
|
|
|
| 64 |
data: { status: "completed", filePath: "generated" }
|
| 65 |
});
|
| 66 |
|
| 67 |
+
// Log del uso de crédito
|
| 68 |
+
await logResourceUsage(db, userId || "anonymous", "images_per_month", contentRecord.id);
|
| 69 |
+
|
| 70 |
await db.agentTask.create({
|
| 71 |
data: { type: "generate_image", status: "completed", input: prompt, output: "Imagen generada", completedAt: new Date() }
|
| 72 |
});
|
| 73 |
|
| 74 |
+
return NextResponse.json({
|
| 75 |
+
success: true,
|
| 76 |
+
image: { id: contentRecord.id, base64: imageBase64, prompt: finalPrompt, platform, size },
|
| 77 |
+
creditsRemaining: creditCheck.remaining ? creditCheck.remaining - 1 : 0
|
| 78 |
+
});
|
| 79 |
} catch (genError) {
|
| 80 |
await db.content.update({ where: { id: contentRecord.id }, data: { status: "failed" } });
|
| 81 |
+
const errorMessage = genError instanceof Error ? genError.message : "Error generando imagen";
|
| 82 |
+
return NextResponse.json({ success: false, error: errorMessage }, { status: 500 });
|
| 83 |
}
|
| 84 |
+
} catch (error: unknown) {
|
| 85 |
+
const errorMessage = error instanceof Error ? error.message : "Error interno";
|
| 86 |
+
return NextResponse.json({ success: false, error: errorMessage }, { status: 500 });
|
| 87 |
}
|
| 88 |
}
|
| 89 |
|
|
|
|
| 91 |
try {
|
| 92 |
const { searchParams } = new URL(request.url);
|
| 93 |
const platform = searchParams.get("platform");
|
| 94 |
+
const limit = Number.parseInt(searchParams.get("limit") || "20", 10);
|
| 95 |
const where: Record<string, any> = { type: "image" };
|
| 96 |
if (platform) where.platform = platform;
|
| 97 |
const images = await db.content.findMany({ where, orderBy: { createdAt: "desc" }, take: limit });
|
src/app/api/influencers/report/route.ts
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { NextRequest, NextResponse } from "next/server";
|
| 2 |
+
import { db } from "@/lib/db";
|
| 3 |
+
|
| 4 |
+
// GET /api/influencers/report?influencerId=<id>
|
| 5 |
+
// Reporting detallado con contabilidad exacta
|
| 6 |
+
export async function GET(request: NextRequest) {
|
| 7 |
+
try {
|
| 8 |
+
const { searchParams } = new URL(request.url);
|
| 9 |
+
const influencerId = searchParams.get("influencerId");
|
| 10 |
+
if (!influencerId) return NextResponse.json({ success: false, error: "influencerId es requerido" }, { status: 400 });
|
| 11 |
+
|
| 12 |
+
// Contar suscriptores por estado
|
| 13 |
+
const activeCount = await db.influencerSubscription.count({ where: { influencerId, status: "active" } });
|
| 14 |
+
const pausedCount = await db.influencerSubscription.count({ where: { influencerId, status: "paused" } });
|
| 15 |
+
const expiredCount = await db.influencerSubscription.count({ where: { influencerId, status: "expired" } });
|
| 16 |
+
const cancelledCount = await db.influencerSubscription.count({ where: { influencerId, status: "cancelled" } });
|
| 17 |
+
|
| 18 |
+
// Obtener todas las suscripciones
|
| 19 |
+
const subs = await db.influencerSubscription.findMany({
|
| 20 |
+
where: { influencerId },
|
| 21 |
+
include: { user: true, earnings: true },
|
| 22 |
+
});
|
| 23 |
+
|
| 24 |
+
// Calcular ingresos exactos desde Earning (contabilidad real)
|
| 25 |
+
const earnings = await db.earning.findMany({
|
| 26 |
+
where: { subscription: { influencerId } },
|
| 27 |
+
});
|
| 28 |
+
|
| 29 |
+
const totalRevenue = earnings.reduce((sum, e) => sum + (e.status === "completed" ? e.amount : 0), 0);
|
| 30 |
+
const pendingRevenue = earnings.reduce((sum, e) => sum + (e.status === "pending" ? e.amount : 0), 0);
|
| 31 |
+
const failedRevenue = earnings.reduce((sum, e) => sum + (e.status === "failed" ? e.amount : 0), 0);
|
| 32 |
+
|
| 33 |
+
return NextResponse.json({
|
| 34 |
+
success: true,
|
| 35 |
+
influencerId,
|
| 36 |
+
summary: {
|
| 37 |
+
activeSubscribers: activeCount,
|
| 38 |
+
pausedSubscribers: pausedCount,
|
| 39 |
+
expiredSubscribers: expiredCount,
|
| 40 |
+
cancelledSubscribers: cancelledCount,
|
| 41 |
+
totalSubscribers: activeCount + pausedCount + expiredCount + cancelledCount,
|
| 42 |
+
},
|
| 43 |
+
revenue: {
|
| 44 |
+
completed: totalRevenue,
|
| 45 |
+
pending: pendingRevenue,
|
| 46 |
+
failed: failedRevenue,
|
| 47 |
+
total: totalRevenue + pendingRevenue,
|
| 48 |
+
},
|
| 49 |
+
subscriptions: subs.map((s) => ({
|
| 50 |
+
id: s.id,
|
| 51 |
+
userId: s.userId,
|
| 52 |
+
userName: s.user?.name || "Unknown",
|
| 53 |
+
tier: s.tier,
|
| 54 |
+
status: s.status,
|
| 55 |
+
price: s.price,
|
| 56 |
+
joinedAt: s.joinedAt,
|
| 57 |
+
nextRenewalDate: s.nextRenewalDate,
|
| 58 |
+
autoRenew: s.autoRenew,
|
| 59 |
+
})),
|
| 60 |
+
paymentDetails: earnings.map((e) => ({
|
| 61 |
+
id: e.id,
|
| 62 |
+
amount: e.amount,
|
| 63 |
+
status: e.status,
|
| 64 |
+
type: e.type,
|
| 65 |
+
isRecurring: e.isRecurring,
|
| 66 |
+
description: e.description,
|
| 67 |
+
createdAt: e.createdAt,
|
| 68 |
+
})),
|
| 69 |
+
});
|
| 70 |
+
} catch (error) {
|
| 71 |
+
const message = error instanceof Error ? error.message : "Error";
|
| 72 |
+
return NextResponse.json({ success: false, error: message }, { status: 500 });
|
| 73 |
+
}
|
| 74 |
+
}
|
| 75 |
+
|
| 76 |
+
// POST /api/influencers/report - acciones administrativas
|
| 77 |
+
export async function POST(request: NextRequest) {
|
| 78 |
+
try {
|
| 79 |
+
const body = await request.json();
|
| 80 |
+
const { action, subscriptionId } = body;
|
| 81 |
+
if (!action || !subscriptionId) return NextResponse.json({ success: false, error: "action y subscriptionId son requeridos" }, { status: 400 });
|
| 82 |
+
|
| 83 |
+
const sub = await db.influencerSubscription.findUnique({ where: { id: subscriptionId } });
|
| 84 |
+
if (!sub) return NextResponse.json({ success: false, error: "Suscripción no encontrada" }, { status: 404 });
|
| 85 |
+
|
| 86 |
+
if (action === "expire") {
|
| 87 |
+
const updated = await db.influencerSubscription.update({
|
| 88 |
+
where: { id: subscriptionId },
|
| 89 |
+
data: { status: "expired", expiresAt: new Date() },
|
| 90 |
+
});
|
| 91 |
+
return NextResponse.json({ success: true, message: "Suscripción expirada", subscription: updated });
|
| 92 |
+
}
|
| 93 |
+
|
| 94 |
+
if (action === "pause") {
|
| 95 |
+
const updated = await db.influencerSubscription.update({
|
| 96 |
+
where: { id: subscriptionId },
|
| 97 |
+
data: { status: "paused" },
|
| 98 |
+
});
|
| 99 |
+
return NextResponse.json({ success: true, message: "Suscripción pausada", subscription: updated });
|
| 100 |
+
}
|
| 101 |
+
|
| 102 |
+
if (action === "resume") {
|
| 103 |
+
const updated = await db.influencerSubscription.update({
|
| 104 |
+
where: { id: subscriptionId },
|
| 105 |
+
data: { status: "active" },
|
| 106 |
+
});
|
| 107 |
+
return NextResponse.json({ success: true, message: "Suscripción reanudada", subscription: updated });
|
| 108 |
+
}
|
| 109 |
+
|
| 110 |
+
return NextResponse.json({ success: false, error: "action desconocida" }, { status: 400 });
|
| 111 |
+
} catch (error) {
|
| 112 |
+
const message = error instanceof Error ? error.message : "Error";
|
| 113 |
+
return NextResponse.json({ success: false, error: message }, { status: 500 });
|
| 114 |
+
}
|
| 115 |
+
}
|
src/app/api/influencers/route.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
| 1 |
import { NextRequest, NextResponse } from "next/server";
|
| 2 |
import ZAI from "z-ai-web-dev-sdk";
|
| 3 |
import { db } from "@/lib/db";
|
|
|
|
| 4 |
|
| 5 |
const FAMOUS_INFLUENCERS = [
|
| 6 |
{ name: "Lil Miquela", handle: "@lilmiquela", platform: "Instagram", followers: 2500000, engagement: 3.2, niche: "Fashion", petCompanion: false, lessons: ["Consistencia visual", "Narrativa creible"] },
|
|
@@ -29,25 +30,108 @@ export async function GET(request: NextRequest) {
|
|
| 29 |
export async function POST(request: NextRequest) {
|
| 30 |
try {
|
| 31 |
const body = await request.json();
|
| 32 |
-
const { targetNiche, includePets } = body;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 33 |
|
| 34 |
const zai = await ZAI.create();
|
|
|
|
|
|
|
| 35 |
const completion = await zai.chat.completions.create({
|
| 36 |
messages: [
|
| 37 |
-
{
|
| 38 |
-
|
| 39 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 40 |
});
|
| 41 |
|
| 42 |
-
let
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 43 |
try {
|
| 44 |
-
const
|
| 45 |
-
|
| 46 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 47 |
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 51 |
}
|
| 52 |
}
|
| 53 |
|
|
|
|
| 1 |
import { NextRequest, NextResponse } from "next/server";
|
| 2 |
import ZAI from "z-ai-web-dev-sdk";
|
| 3 |
import { db } from "@/lib/db";
|
| 4 |
+
import { validateUserCredit, logResourceUsage } from "@/lib/credits";
|
| 5 |
|
| 6 |
const FAMOUS_INFLUENCERS = [
|
| 7 |
{ name: "Lil Miquela", handle: "@lilmiquela", platform: "Instagram", followers: 2500000, engagement: 3.2, niche: "Fashion", petCompanion: false, lessons: ["Consistencia visual", "Narrativa creible"] },
|
|
|
|
| 30 |
export async function POST(request: NextRequest) {
|
| 31 |
try {
|
| 32 |
const body = await request.json();
|
| 33 |
+
const { targetNiche, includePets, userId, tier = "free" } = body;
|
| 34 |
+
|
| 35 |
+
// Validar créditos
|
| 36 |
+
const creditCheck = await validateUserCredit(db, userId || "anonymous", "influencers_per_month", tier);
|
| 37 |
+
if (!creditCheck.allowed) {
|
| 38 |
+
return NextResponse.json({ success: false, error: creditCheck.reason, remaining: creditCheck.remaining }, { status: 429 });
|
| 39 |
+
}
|
| 40 |
|
| 41 |
const zai = await ZAI.create();
|
| 42 |
+
|
| 43 |
+
// Generar análisis Y datos del influencer automáticamente
|
| 44 |
const completion = await zai.chat.completions.create({
|
| 45 |
messages: [
|
| 46 |
+
{
|
| 47 |
+
role: "system",
|
| 48 |
+
content: `Eres experto en crear influencers virtuales. Responde SOLO con JSON válido (sin markdown):
|
| 49 |
+
{
|
| 50 |
+
"name": "nombre único del influencer",
|
| 51 |
+
"handle": "@handle",
|
| 52 |
+
"platform": "Instagram|TikTok|YouTube",
|
| 53 |
+
"followers": número,
|
| 54 |
+
"engagement": número entre 0-10,
|
| 55 |
+
"niche": "nicho específico",
|
| 56 |
+
"style": "descripción del estilo",
|
| 57 |
+
"contentTypes": "tipos de contenido",
|
| 58 |
+
"postingSchedule": "frecuencia de posts",
|
| 59 |
+
"visualStyle": "descripción visual",
|
| 60 |
+
"monetizationType": "tipo de monetización",
|
| 61 |
+
"signatureElements": "elementos únicos",
|
| 62 |
+
"petCompanion": boolean,
|
| 63 |
+
"petType": "tipo de mascota o null"
|
| 64 |
+
}`
|
| 65 |
+
},
|
| 66 |
+
{
|
| 67 |
+
role: "user",
|
| 68 |
+
content: `Crea un influencer único para el nicho: ${targetNiche || "general"}. ${includePets ? "Que tenga mascota." : "Sin mascota."} Hazlo creativo y único.`
|
| 69 |
+
}
|
| 70 |
+
],
|
| 71 |
+
temperature: 0.9,
|
| 72 |
+
max_tokens: 1200
|
| 73 |
});
|
| 74 |
|
| 75 |
+
let influencerData: Record<string, any> = {
|
| 76 |
+
name: "Influencer Generado",
|
| 77 |
+
platform: "Instagram",
|
| 78 |
+
niche: targetNiche || "general",
|
| 79 |
+
petCompanion: includePets || false
|
| 80 |
+
};
|
| 81 |
+
|
| 82 |
try {
|
| 83 |
+
const content = completion.choices[0]?.message?.content || "";
|
| 84 |
+
const jsonMatch = content.match(/\{[\s\S]*\}/);
|
| 85 |
+
if (jsonMatch) {
|
| 86 |
+
const parsed = JSON.parse(jsonMatch[0]);
|
| 87 |
+
influencerData = { ...influencerData, ...parsed };
|
| 88 |
+
}
|
| 89 |
+
} catch (parseError) {
|
| 90 |
+
console.warn("Error parsing influencer data, using defaults", parseError);
|
| 91 |
+
}
|
| 92 |
|
| 93 |
+
// Crear influencer automáticamente
|
| 94 |
+
const influencer = await db.aIInfluencer.create({
|
| 95 |
+
data: {
|
| 96 |
+
name: influencerData.name,
|
| 97 |
+
handle: influencerData.handle || null,
|
| 98 |
+
platform: influencerData.platform,
|
| 99 |
+
followers: influencerData.followers || null,
|
| 100 |
+
engagement: influencerData.engagement || null,
|
| 101 |
+
niche: influencerData.niche,
|
| 102 |
+
style: influencerData.style || null,
|
| 103 |
+
contentTypes: influencerData.contentTypes ? JSON.stringify(influencerData.contentTypes) : null,
|
| 104 |
+
postingSchedule: influencerData.postingSchedule || null,
|
| 105 |
+
visualStyle: influencerData.visualStyle || null,
|
| 106 |
+
monetizationType: influencerData.monetizationType || null,
|
| 107 |
+
signatureElements: influencerData.signatureElements ? JSON.stringify(influencerData.signatureElements) : null,
|
| 108 |
+
petCompanion: influencerData.petCompanion || false,
|
| 109 |
+
petType: influencerData.petType || null,
|
| 110 |
+
}
|
| 111 |
+
});
|
| 112 |
+
|
| 113 |
+
// Log del uso de crédito
|
| 114 |
+
await logResourceUsage(db, userId || "anonymous", "influencers_per_month", influencer.id);
|
| 115 |
+
|
| 116 |
+
// Registrar tarea completada
|
| 117 |
+
await db.agentTask.create({
|
| 118 |
+
data: {
|
| 119 |
+
type: "create_influencer",
|
| 120 |
+
status: "completed",
|
| 121 |
+
input: targetNiche || "general",
|
| 122 |
+
output: influencer.id,
|
| 123 |
+
completedAt: new Date()
|
| 124 |
+
}
|
| 125 |
+
});
|
| 126 |
+
|
| 127 |
+
return NextResponse.json({
|
| 128 |
+
success: true,
|
| 129 |
+
influencer,
|
| 130 |
+
creditsRemaining: creditCheck.remaining ? creditCheck.remaining - 1 : 0
|
| 131 |
+
});
|
| 132 |
+
} catch (error: unknown) {
|
| 133 |
+
const message = error instanceof Error ? error.message : "Error desconocido";
|
| 134 |
+
return NextResponse.json({ success: false, error: message }, { status: 500 });
|
| 135 |
}
|
| 136 |
}
|
| 137 |
|
src/app/api/influencers/subscription/cancel/route.ts
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { NextRequest, NextResponse } from "next/server";
|
| 2 |
+
import { db } from "@/lib/db";
|
| 3 |
+
import { cancelStripeSubscription } from "@/lib/stripe";
|
| 4 |
+
|
| 5 |
+
// Endpoint para cancelar suscripción
|
| 6 |
+
// DELETE /api/influencers/subscription/cancel?id=<subscription-id>
|
| 7 |
+
export async function POST(request: NextRequest) {
|
| 8 |
+
try {
|
| 9 |
+
const body = await request.json();
|
| 10 |
+
const { subscriptionId } = body;
|
| 11 |
+
|
| 12 |
+
if (!subscriptionId) {
|
| 13 |
+
return NextResponse.json({ success: false, error: "subscriptionId es requerido" }, { status: 400 });
|
| 14 |
+
}
|
| 15 |
+
|
| 16 |
+
const sub = await db.influencerSubscription.findUnique({ where: { id: subscriptionId } });
|
| 17 |
+
if (!sub) return NextResponse.json({ success: false, error: "Suscripción no encontrada" }, { status: 404 });
|
| 18 |
+
|
| 19 |
+
// Si tiene stripeSubscriptionId, cancelar en Stripe también
|
| 20 |
+
if (sub.stripeSubscriptionId) {
|
| 21 |
+
try {
|
| 22 |
+
await cancelStripeSubscription(sub.stripeSubscriptionId);
|
| 23 |
+
} catch (stripeError) {
|
| 24 |
+
console.error("Error cancelando en Stripe:", stripeError);
|
| 25 |
+
// Continuar de todas formas para permitir cancelar localmente
|
| 26 |
+
}
|
| 27 |
+
}
|
| 28 |
+
|
| 29 |
+
// Marcar como cancelada en BD
|
| 30 |
+
const updated = await db.influencerSubscription.update({
|
| 31 |
+
where: { id: subscriptionId },
|
| 32 |
+
data: {
|
| 33 |
+
status: "cancelled",
|
| 34 |
+
autoRenew: false,
|
| 35 |
+
expiresAt: new Date(), // Expirar inmediatamente
|
| 36 |
+
},
|
| 37 |
+
});
|
| 38 |
+
|
| 39 |
+
try {
|
| 40 |
+
await db.agentTask.create({
|
| 41 |
+
data: {
|
| 42 |
+
type: "cancel_subscription",
|
| 43 |
+
status: "completed",
|
| 44 |
+
input: subscriptionId,
|
| 45 |
+
output: "cancelled",
|
| 46 |
+
completedAt: new Date(),
|
| 47 |
+
},
|
| 48 |
+
});
|
| 49 |
+
} catch { }
|
| 50 |
+
|
| 51 |
+
return NextResponse.json({
|
| 52 |
+
success: true,
|
| 53 |
+
message: "Suscripción cancelada correctamente",
|
| 54 |
+
subscription: updated,
|
| 55 |
+
});
|
| 56 |
+
} catch (error: unknown) {
|
| 57 |
+
const message = error instanceof Error ? error.message : "Error desconocido";
|
| 58 |
+
return NextResponse.json({ success: false, error: message }, { status: 500 });
|
| 59 |
+
}
|
| 60 |
+
}
|
src/app/api/influencers/subscription/route.ts
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { NextRequest, NextResponse } from "next/server";
|
| 2 |
+
import { db } from "@/lib/db";
|
| 3 |
+
|
| 4 |
+
export async function POST(request: NextRequest) {
|
| 5 |
+
try {
|
| 6 |
+
const body = await request.json();
|
| 7 |
+
const { userId, influencerId, tier, price, currency = "USD", expiresAt, stripeSubscriptionId, stripeCustomerId, autoRenew = true } = body;
|
| 8 |
+
|
| 9 |
+
if (!userId || !influencerId || !tier) {
|
| 10 |
+
return NextResponse.json({ success: false, error: "userId, influencerId y tier son requeridos" }, { status: 400 });
|
| 11 |
+
}
|
| 12 |
+
|
| 13 |
+
// Verificar que influencer existe
|
| 14 |
+
const influencer = await db.aIInfluencer.findUnique({ where: { id: influencerId } });
|
| 15 |
+
if (!influencer) return NextResponse.json({ success: false, error: "Influencer no encontrado" }, { status: 404 });
|
| 16 |
+
|
| 17 |
+
// Verificar que el usuario existe
|
| 18 |
+
const user = await db.user.findUnique({ where: { id: userId } });
|
| 19 |
+
if (!user) return NextResponse.json({ success: false, error: "Usuario no encontrado" }, { status: 404 });
|
| 20 |
+
|
| 21 |
+
// Política ACTUALIZADA: permitir cualquier tier pagando (sin restricción)
|
| 22 |
+
const nextRenewalDate = autoRenew ? new Date(Date.now() + 30 * 24 * 60 * 60 * 1000) : null; // 30 días
|
| 23 |
+
|
| 24 |
+
const subscription = await db.influencerSubscription.create({
|
| 25 |
+
data: {
|
| 26 |
+
influencerId,
|
| 27 |
+
userId,
|
| 28 |
+
tier,
|
| 29 |
+
price: price ?? null,
|
| 30 |
+
currency: currency,
|
| 31 |
+
expiresAt: expiresAt ? new Date(expiresAt) : null,
|
| 32 |
+
nextRenewalDate,
|
| 33 |
+
autoRenew,
|
| 34 |
+
stripeSubscriptionId: stripeSubscriptionId || null,
|
| 35 |
+
stripeCustomerId: stripeCustomerId || null,
|
| 36 |
+
}
|
| 37 |
+
});
|
| 38 |
+
|
| 39 |
+
// Registrar tarea para auditoría
|
| 40 |
+
try {
|
| 41 |
+
await db.agentTask.create({ data: { type: "subscribe_influencer", status: "completed", input: JSON.stringify({ userId, influencerId, tier }), output: subscription.id, completedAt: new Date() } });
|
| 42 |
+
} catch { }
|
| 43 |
+
|
| 44 |
+
return NextResponse.json({ success: true, subscription });
|
| 45 |
+
} catch (error: unknown) {
|
| 46 |
+
const message = error instanceof Error ? error.message : "Error desconocido";
|
| 47 |
+
return NextResponse.json({ success: false, error: message }, { status: 500 });
|
| 48 |
+
}
|
| 49 |
+
}
|
| 50 |
+
|
| 51 |
+
export async function GET(request: NextRequest) {
|
| 52 |
+
try {
|
| 53 |
+
const { searchParams } = new URL(request.url);
|
| 54 |
+
const userId = searchParams.get("userId");
|
| 55 |
+
const influencerId = searchParams.get("influencerId");
|
| 56 |
+
|
| 57 |
+
const where: any = {};
|
| 58 |
+
if (userId) where.userId = userId;
|
| 59 |
+
if (influencerId) where.influencerId = influencerId;
|
| 60 |
+
|
| 61 |
+
const subs = await db.influencerSubscription.findMany({ where, orderBy: { createdAt: "desc" } });
|
| 62 |
+
return NextResponse.json({ success: true, subscriptions: subs });
|
| 63 |
+
} catch {
|
| 64 |
+
return NextResponse.json({ success: false, error: "Error" }, { status: 500 });
|
| 65 |
+
}
|
| 66 |
+
}
|
| 67 |
+
|
| 68 |
+
export async function DELETE(request: NextRequest) {
|
| 69 |
+
try {
|
| 70 |
+
const { searchParams } = new URL(request.url);
|
| 71 |
+
const id = searchParams.get("id");
|
| 72 |
+
if (!id) return NextResponse.json({ success: false, error: "id requerido" }, { status: 400 });
|
| 73 |
+
|
| 74 |
+
const sub = await db.influencerSubscription.findUnique({ where: { id } });
|
| 75 |
+
if (!sub) return NextResponse.json({ success: false, error: "Suscripción no encontrada" }, { status: 404 });
|
| 76 |
+
|
| 77 |
+
// Marcar como cancelada (soft delete)
|
| 78 |
+
await db.influencerSubscription.update({
|
| 79 |
+
where: { id },
|
| 80 |
+
data: { status: "cancelled", autoRenew: false }
|
| 81 |
+
});
|
| 82 |
+
|
| 83 |
+
try { await db.agentTask.create({ data: { type: "unsubscribe_influencer", status: "completed", input: id, output: "cancelled", completedAt: new Date() } }); } catch { }
|
| 84 |
+
return NextResponse.json({ success: true, message: "Suscripción cancelada" });
|
| 85 |
+
} catch (error) {
|
| 86 |
+
const message = error instanceof Error ? error.message : "Error";
|
| 87 |
+
return NextResponse.json({ success: false, error: message }, { status: 500 });
|
| 88 |
+
}
|
| 89 |
+
}
|
src/app/api/payments/checkout/route.ts
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { NextRequest, NextResponse } from "next/server";
|
| 2 |
+
import { db } from "@/lib/db";
|
| 3 |
+
import { createCheckoutSession, getOrCreateStripeCustomer } from "@/lib/stripe";
|
| 4 |
+
|
| 5 |
+
// Endpoint para iniciar checkout de Stripe
|
| 6 |
+
// Body: { userId, influencerId, tier, price }
|
| 7 |
+
export async function POST(request: NextRequest) {
|
| 8 |
+
try {
|
| 9 |
+
const body = await request.json();
|
| 10 |
+
const { userId, influencerId, tier, price } = body;
|
| 11 |
+
const origin = request.headers.get("origin") || "http://localhost:7860";
|
| 12 |
+
|
| 13 |
+
if (!userId || !influencerId || !tier || !price) {
|
| 14 |
+
return NextResponse.json({ success: false, error: "userId, influencerId, tier y price son requeridos" }, { status: 400 });
|
| 15 |
+
}
|
| 16 |
+
|
| 17 |
+
// Obtener usuario e influencer
|
| 18 |
+
const user = await db.user.findUnique({ where: { id: userId } });
|
| 19 |
+
if (!user) return NextResponse.json({ success: false, error: "Usuario no encontrado" }, { status: 404 });
|
| 20 |
+
|
| 21 |
+
const influencer = await db.aIInfluencer.findUnique({ where: { id: influencerId } });
|
| 22 |
+
if (!influencer) return NextResponse.json({ success: false, error: "Influencer no encontrado" }, { status: 404 });
|
| 23 |
+
|
| 24 |
+
// Crear o obtener cliente Stripe
|
| 25 |
+
const email = user.email || `user-${userId}@dev.local`;
|
| 26 |
+
const stripeCustomerId = await getOrCreateStripeCustomer(userId, email, user.name || undefined);
|
| 27 |
+
|
| 28 |
+
// Crear sesión de checkout
|
| 29 |
+
const sessionId = await createCheckoutSession({
|
| 30 |
+
userId,
|
| 31 |
+
influencerId,
|
| 32 |
+
influencerName: influencer.name,
|
| 33 |
+
tier,
|
| 34 |
+
price,
|
| 35 |
+
stripeCustomerId,
|
| 36 |
+
origin,
|
| 37 |
+
});
|
| 38 |
+
|
| 39 |
+
return NextResponse.json({ success: true, sessionId, checkoutUrl: `https://checkout.stripe.com/pay/${sessionId}` });
|
| 40 |
+
} catch (error: unknown) {
|
| 41 |
+
const message = error instanceof Error ? error.message : "Error desconocido";
|
| 42 |
+
console.error("Checkout error:", message);
|
| 43 |
+
return NextResponse.json({ success: false, error: message }, { status: 500 });
|
| 44 |
+
}
|
| 45 |
+
}
|
src/app/api/payments/stripe-webhook/route.ts
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { NextRequest, NextResponse } from "next/server";
|
| 2 |
+
import { db } from "@/lib/db";
|
| 3 |
+
|
| 4 |
+
// Webhook minimalista: espera eventos JSON con la forma { type: 'payment.succeeded', data: { userId, influencerId, tier, price, currency } }
|
| 5 |
+
// No depende de la librería Stripe. Para producción, valida firma con STRIPE_WEBHOOK_SECRET.
|
| 6 |
+
|
| 7 |
+
export async function POST(request: NextRequest) {
|
| 8 |
+
try {
|
| 9 |
+
const secret = process.env.STRIPE_WEBHOOK_SECRET || null;
|
| 10 |
+
// TODO: validar firma si STRIPE_WEBHOOK_SECRET está presente
|
| 11 |
+
|
| 12 |
+
const event = await request.json();
|
| 13 |
+
if (!event || !event.type) return NextResponse.json({ success: false, error: "Evento inválido" }, { status: 400 });
|
| 14 |
+
|
| 15 |
+
if (event.type === "payment.succeeded") {
|
| 16 |
+
const d = event.data || {};
|
| 17 |
+
const { userId, influencerId, tier, price, currency, expiresAt } = d;
|
| 18 |
+
if (!userId || !influencerId || !tier) return NextResponse.json({ success: false, error: "Datos incompletos en evento" }, { status: 400 });
|
| 19 |
+
|
| 20 |
+
// Crear suscripción en BD
|
| 21 |
+
const subscription = await db.influencerSubscription.create({ data: {
|
| 22 |
+
influencerId,
|
| 23 |
+
userId,
|
| 24 |
+
tier,
|
| 25 |
+
price: price ?? null,
|
| 26 |
+
currency: currency ?? "USD",
|
| 27 |
+
expiresAt: expiresAt ? new Date(expiresAt) : null,
|
| 28 |
+
}});
|
| 29 |
+
|
| 30 |
+
try { await db.agentTask.create({ data: { type: "payment_webhook_subscribe", status: "completed", input: JSON.stringify(d), output: subscription.id, completedAt: new Date() } }); } catch { }
|
| 31 |
+
|
| 32 |
+
return NextResponse.json({ success: true, subscription });
|
| 33 |
+
}
|
| 34 |
+
|
| 35 |
+
return NextResponse.json({ success: true, received: true });
|
| 36 |
+
} catch (error) {
|
| 37 |
+
const message = error instanceof Error ? error.message : "Error";
|
| 38 |
+
return NextResponse.json({ success: false, error: message }, { status: 500 });
|
| 39 |
+
}
|
| 40 |
+
}
|
src/app/api/payments/webhook/route.ts
ADDED
|
@@ -0,0 +1,207 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { NextRequest, NextResponse } from "next/server";
|
| 2 |
+
import { db } from "@/lib/db";
|
| 3 |
+
import { stripe, parseWebhookEvent } from "@/lib/stripe";
|
| 4 |
+
|
| 5 |
+
// Webhook de Stripe mejorado con validación de firma
|
| 6 |
+
export async function POST(request: NextRequest) {
|
| 7 |
+
try {
|
| 8 |
+
const signature = request.headers.get("stripe-signature");
|
| 9 |
+
if (!signature) return NextResponse.json({ success: false, error: "Firma faltante" }, { status: 400 });
|
| 10 |
+
|
| 11 |
+
const body = await request.text();
|
| 12 |
+
|
| 13 |
+
// Parsear y validar webhook
|
| 14 |
+
let event;
|
| 15 |
+
try {
|
| 16 |
+
event = parseWebhookEvent(body, signature);
|
| 17 |
+
} catch (err) {
|
| 18 |
+
console.error("Firma de webhook inválida:", err);
|
| 19 |
+
return NextResponse.json({ success: false, error: "Firma de webhook inválida" }, { status: 403 });
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
+
// Manejar eventos de Stripe
|
| 23 |
+
switch (event.type) {
|
| 24 |
+
case "checkout.session.completed":
|
| 25 |
+
return await handleCheckoutCompleted(event.data.object);
|
| 26 |
+
|
| 27 |
+
case "customer.subscription.updated":
|
| 28 |
+
return await handleSubscriptionUpdated(event.data.object);
|
| 29 |
+
|
| 30 |
+
case "customer.subscription.deleted":
|
| 31 |
+
return await handleSubscriptionDeleted(event.data.object);
|
| 32 |
+
|
| 33 |
+
case "invoice.payment_succeeded":
|
| 34 |
+
return await handleInvoicePaymentSucceeded(event.data.object);
|
| 35 |
+
|
| 36 |
+
case "invoice.payment_failed":
|
| 37 |
+
return await handleInvoicePaymentFailed(event.data.object);
|
| 38 |
+
|
| 39 |
+
default:
|
| 40 |
+
console.log(`Evento no manejado: ${event.type}`);
|
| 41 |
+
return NextResponse.json({ success: true, received: true });
|
| 42 |
+
}
|
| 43 |
+
} catch (error: unknown) {
|
| 44 |
+
const message = error instanceof Error ? error.message : "Error desconocido";
|
| 45 |
+
console.error("Webhook error:", message);
|
| 46 |
+
return NextResponse.json({ success: false, error: message }, { status: 500 });
|
| 47 |
+
}
|
| 48 |
+
}
|
| 49 |
+
|
| 50 |
+
// Sesión de checkout completada: crear suscripción
|
| 51 |
+
async function handleCheckoutCompleted(session: any) {
|
| 52 |
+
try {
|
| 53 |
+
const userId = session.metadata?.userId;
|
| 54 |
+
const influencerId = session.metadata?.influencerId;
|
| 55 |
+
const tier = session.metadata?.tier;
|
| 56 |
+
const stripeCustomerId = session.customer;
|
| 57 |
+
const stripeSubscriptionId = session.subscription;
|
| 58 |
+
|
| 59 |
+
if (!userId || !influencerId || !tier) {
|
| 60 |
+
console.error("Metadata incompleta en checkout");
|
| 61 |
+
return NextResponse.json({ success: false, error: "Metadata incompleta" }, { status: 400 });
|
| 62 |
+
}
|
| 63 |
+
|
| 64 |
+
// Obtener detalles de la suscripción en Stripe
|
| 65 |
+
const stripeSub = await stripe.subscriptions.retrieve(stripeSubscriptionId);
|
| 66 |
+
const firstItem = stripeSub.items.data[0];
|
| 67 |
+
const price = firstItem?.price?.unit_amount ? firstItem.price.unit_amount / 100 : 0;
|
| 68 |
+
const nextRenewalDate = firstItem ? new Date(firstItem.current_period_end * 1000) : new Date();
|
| 69 |
+
|
| 70 |
+
// Crear suscripción en BD
|
| 71 |
+
const subscription = await db.influencerSubscription.create({
|
| 72 |
+
data: {
|
| 73 |
+
influencerId,
|
| 74 |
+
userId,
|
| 75 |
+
tier,
|
| 76 |
+
price,
|
| 77 |
+
currency: "USD",
|
| 78 |
+
stripeSubscriptionId,
|
| 79 |
+
stripeCustomerId,
|
| 80 |
+
autoRenew: true,
|
| 81 |
+
nextRenewalDate,
|
| 82 |
+
},
|
| 83 |
+
});
|
| 84 |
+
|
| 85 |
+
// TODO: Crear Earning cuando exista un registro real en MonetizationPlatform
|
| 86 |
+
// El platformId debe ser un cuid válido de la tabla MonetizationPlatform
|
| 87 |
+
|
| 88 |
+
console.log(`Suscripción creada: ${subscription.id}`);
|
| 89 |
+
return NextResponse.json({ success: true, subscription });
|
| 90 |
+
} catch (error: unknown) {
|
| 91 |
+
const message = error instanceof Error ? error.message : "Error";
|
| 92 |
+
console.error("Error handleCheckoutCompleted:", message);
|
| 93 |
+
return NextResponse.json({ success: false, error: message }, { status: 500 });
|
| 94 |
+
}
|
| 95 |
+
}
|
| 96 |
+
|
| 97 |
+
// Renovación automática: invoice pagado
|
| 98 |
+
async function handleInvoicePaymentSucceeded(invoice: any) {
|
| 99 |
+
try {
|
| 100 |
+
const stripeSubscriptionId = invoice.subscription;
|
| 101 |
+
if (!stripeSubscriptionId) return NextResponse.json({ success: true });
|
| 102 |
+
|
| 103 |
+
const subscription = await db.influencerSubscription.findFirst({
|
| 104 |
+
where: { stripeSubscriptionId },
|
| 105 |
+
});
|
| 106 |
+
|
| 107 |
+
if (!subscription) {
|
| 108 |
+
console.error(`Suscripción no encontrada: ${stripeSubscriptionId}`);
|
| 109 |
+
return NextResponse.json({ success: true });
|
| 110 |
+
}
|
| 111 |
+
|
| 112 |
+
// Obtener detalles de la suscripción actualizada
|
| 113 |
+
const stripeSub = await stripe.subscriptions.retrieve(stripeSubscriptionId);
|
| 114 |
+
const firstItem = stripeSub.items.data[0];
|
| 115 |
+
const nextRenewalDate = firstItem ? new Date(firstItem.current_period_end * 1000) : new Date();
|
| 116 |
+
const amountPaid = invoice.amount_paid / 100;
|
| 117 |
+
|
| 118 |
+
// Actualizar próxima renovación
|
| 119 |
+
await db.influencerSubscription.update({
|
| 120 |
+
where: { id: subscription.id },
|
| 121 |
+
data: { nextRenewalDate },
|
| 122 |
+
});
|
| 123 |
+
|
| 124 |
+
// TODO: Crear Earning cuando exista un registro real en MonetizationPlatform
|
| 125 |
+
// El platformId debe ser un cuid válido de la tabla MonetizationPlatform
|
| 126 |
+
|
| 127 |
+
console.log(`Renovación procesada: suscripción ${subscription.id}, próxima: ${nextRenewalDate}`);
|
| 128 |
+
return NextResponse.json({ success: true });
|
| 129 |
+
} catch (error: unknown) {
|
| 130 |
+
const message = error instanceof Error ? error.message : "Error";
|
| 131 |
+
console.error("Error handleInvoicePaymentSucceeded:", message);
|
| 132 |
+
return NextResponse.json({ success: false, error: message }, { status: 500 });
|
| 133 |
+
}
|
| 134 |
+
}
|
| 135 |
+
|
| 136 |
+
// Pago de renovación fallido: marcar suscripción para aviso
|
| 137 |
+
async function handleInvoicePaymentFailed(invoice: any) {
|
| 138 |
+
try {
|
| 139 |
+
const stripeSubscriptionId = invoice.subscription;
|
| 140 |
+
if (!stripeSubscriptionId) return NextResponse.json({ success: true });
|
| 141 |
+
|
| 142 |
+
const subscription = await db.influencerSubscription.findFirst({
|
| 143 |
+
where: { stripeSubscriptionId },
|
| 144 |
+
});
|
| 145 |
+
|
| 146 |
+
if (!subscription) return NextResponse.json({ success: true });
|
| 147 |
+
|
| 148 |
+
// Registrar intento fallido
|
| 149 |
+
console.error(`Pago fallido para suscripción: ${subscription.id}`);
|
| 150 |
+
// TODO: notificar al usuario
|
| 151 |
+
|
| 152 |
+
return NextResponse.json({ success: true });
|
| 153 |
+
} catch (error: unknown) {
|
| 154 |
+
const message = error instanceof Error ? error.message : "Error";
|
| 155 |
+
console.error("Error handleInvoicePaymentFailed:", message);
|
| 156 |
+
return NextResponse.json({ success: false, error: message }, { status: 500 });
|
| 157 |
+
}
|
| 158 |
+
}
|
| 159 |
+
|
| 160 |
+
// Suscripción actualizada: cambios de plan, estado, etc.
|
| 161 |
+
async function handleSubscriptionUpdated(stripeSub: any) {
|
| 162 |
+
try {
|
| 163 |
+
const subscription = await db.influencerSubscription.findFirst({
|
| 164 |
+
where: { stripeSubscriptionId: stripeSub.id },
|
| 165 |
+
});
|
| 166 |
+
|
| 167 |
+
if (!subscription) return NextResponse.json({ success: true });
|
| 168 |
+
|
| 169 |
+
const nextRenewalDate = stripeSub.cancel_at ? new Date(stripeSub.cancel_at * 1000) : new Date(stripeSub.current_period_end * 1000);
|
| 170 |
+
|
| 171 |
+
await db.influencerSubscription.update({
|
| 172 |
+
where: { id: subscription.id },
|
| 173 |
+
data: { nextRenewalDate },
|
| 174 |
+
});
|
| 175 |
+
|
| 176 |
+
console.log(`Suscripción actualizada: ${subscription.id}`);
|
| 177 |
+
return NextResponse.json({ success: true });
|
| 178 |
+
} catch (error: unknown) {
|
| 179 |
+
const message = error instanceof Error ? error.message : "Error";
|
| 180 |
+
console.error("Error handleSubscriptionUpdated:", message);
|
| 181 |
+
return NextResponse.json({ success: false, error: message }, { status: 500 });
|
| 182 |
+
}
|
| 183 |
+
}
|
| 184 |
+
|
| 185 |
+
// Suscripción eliminada/cancelada
|
| 186 |
+
async function handleSubscriptionDeleted(stripeSub: any) {
|
| 187 |
+
try {
|
| 188 |
+
const subscription = await db.influencerSubscription.findFirst({
|
| 189 |
+
where: { stripeSubscriptionId: stripeSub.id },
|
| 190 |
+
});
|
| 191 |
+
|
| 192 |
+
if (!subscription) return NextResponse.json({ success: true });
|
| 193 |
+
|
| 194 |
+
// Marcar como cancelada
|
| 195 |
+
await db.influencerSubscription.update({
|
| 196 |
+
where: { id: subscription.id },
|
| 197 |
+
data: { status: "cancelled", autoRenew: false },
|
| 198 |
+
});
|
| 199 |
+
|
| 200 |
+
console.log(`Suscripción cancelada: ${subscription.id}`);
|
| 201 |
+
return NextResponse.json({ success: true });
|
| 202 |
+
} catch (error: unknown) {
|
| 203 |
+
const message = error instanceof Error ? error.message : "Error";
|
| 204 |
+
console.error("Error handleSubscriptionDeleted:", message);
|
| 205 |
+
return NextResponse.json({ success: false, error: message }, { status: 500 });
|
| 206 |
+
}
|
| 207 |
+
}
|
src/app/api/storytelling/route.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
| 1 |
import { NextRequest, NextResponse } from "next/server";
|
| 2 |
import { db } from "@/lib/db";
|
| 3 |
import ZAI from "z-ai-web-dev-sdk";
|
|
|
|
| 4 |
|
| 5 |
const GENRES = [
|
| 6 |
{ id: "romance", name: "Romance" },
|
|
@@ -32,12 +33,18 @@ export async function GET(request: NextRequest) {
|
|
| 32 |
export async function POST(request: NextRequest) {
|
| 33 |
try {
|
| 34 |
const body = await request.json();
|
| 35 |
-
const { prompt, genre, totalEpisodes } = body;
|
| 36 |
|
| 37 |
if (!prompt) {
|
| 38 |
return NextResponse.json({ success: false, error: "Prompt requerido" }, { status: 400 });
|
| 39 |
}
|
| 40 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 41 |
const zai = await ZAI.create();
|
| 42 |
const completion = await zai.chat.completions.create({
|
| 43 |
messages: [
|
|
@@ -79,6 +86,10 @@ export async function POST(request: NextRequest) {
|
|
| 79 |
}
|
| 80 |
|
| 81 |
await db.storyAnalytics.create({ data: { storyId: story.id } });
|
|
|
|
|
|
|
|
|
|
|
|
|
| 82 |
await db.agentTask.create({
|
| 83 |
data: { type: "create_story", status: "completed", input: prompt, output: "Historia creada", completedAt: new Date() }
|
| 84 |
});
|
|
@@ -88,9 +99,14 @@ export async function POST(request: NextRequest) {
|
|
| 88 |
include: { episodes: { orderBy: { episodeNum: "asc" } } }
|
| 89 |
});
|
| 90 |
|
| 91 |
-
return NextResponse.json({
|
| 92 |
-
|
| 93 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 94 |
}
|
| 95 |
}
|
| 96 |
|
|
|
|
| 1 |
import { NextRequest, NextResponse } from "next/server";
|
| 2 |
import { db } from "@/lib/db";
|
| 3 |
import ZAI from "z-ai-web-dev-sdk";
|
| 4 |
+
import { validateUserCredit, logResourceUsage } from "@/lib/credits";
|
| 5 |
|
| 6 |
const GENRES = [
|
| 7 |
{ id: "romance", name: "Romance" },
|
|
|
|
| 33 |
export async function POST(request: NextRequest) {
|
| 34 |
try {
|
| 35 |
const body = await request.json();
|
| 36 |
+
const { prompt, genre, totalEpisodes, userId, tier = "free" } = body;
|
| 37 |
|
| 38 |
if (!prompt) {
|
| 39 |
return NextResponse.json({ success: false, error: "Prompt requerido" }, { status: 400 });
|
| 40 |
}
|
| 41 |
|
| 42 |
+
// Validar créditos
|
| 43 |
+
const creditCheck = await validateUserCredit(db, userId || "anonymous", "stories_per_month", tier);
|
| 44 |
+
if (!creditCheck.allowed) {
|
| 45 |
+
return NextResponse.json({ success: false, error: creditCheck.reason, remaining: creditCheck.remaining }, { status: 429 });
|
| 46 |
+
}
|
| 47 |
+
|
| 48 |
const zai = await ZAI.create();
|
| 49 |
const completion = await zai.chat.completions.create({
|
| 50 |
messages: [
|
|
|
|
| 86 |
}
|
| 87 |
|
| 88 |
await db.storyAnalytics.create({ data: { storyId: story.id } });
|
| 89 |
+
|
| 90 |
+
// Log del uso de crédito
|
| 91 |
+
await logResourceUsage(db, userId || "anonymous", "stories_per_month", story.id);
|
| 92 |
+
|
| 93 |
await db.agentTask.create({
|
| 94 |
data: { type: "create_story", status: "completed", input: prompt, output: "Historia creada", completedAt: new Date() }
|
| 95 |
});
|
|
|
|
| 99 |
include: { episodes: { orderBy: { episodeNum: "asc" } } }
|
| 100 |
});
|
| 101 |
|
| 102 |
+
return NextResponse.json({
|
| 103 |
+
success: true,
|
| 104 |
+
story: fullStory,
|
| 105 |
+
creditsRemaining: creditCheck.remaining ? creditCheck.remaining - 1 : 0
|
| 106 |
+
});
|
| 107 |
+
} catch (error: unknown) {
|
| 108 |
+
const message = error instanceof Error ? error.message : "Error desconocido";
|
| 109 |
+
return NextResponse.json({ success: false, error: message }, { status: 500 });
|
| 110 |
}
|
| 111 |
}
|
| 112 |
|
src/app/layout.tsx
CHANGED
|
@@ -1,7 +1,7 @@
|
|
| 1 |
import type { Metadata } from "next";
|
| 2 |
import { Geist, Geist_Mono } from "next/font/google";
|
| 3 |
import "./globals.css";
|
| 4 |
-
import { Toaster } from "
|
| 5 |
|
| 6 |
const geistSans = Geist({
|
| 7 |
variable: "--font-geist-sans",
|
|
@@ -44,7 +44,7 @@ export default function RootLayout({
|
|
| 44 |
className={`${geistSans.variable} ${geistMono.variable} antialiased bg-background text-foreground`}
|
| 45 |
>
|
| 46 |
{children}
|
| 47 |
-
<Toaster />
|
| 48 |
</body>
|
| 49 |
</html>
|
| 50 |
);
|
|
|
|
| 1 |
import type { Metadata } from "next";
|
| 2 |
import { Geist, Geist_Mono } from "next/font/google";
|
| 3 |
import "./globals.css";
|
| 4 |
+
import { Toaster } from "sonner";
|
| 5 |
|
| 6 |
const geistSans = Geist({
|
| 7 |
variable: "--font-geist-sans",
|
|
|
|
| 44 |
className={`${geistSans.variable} ${geistMono.variable} antialiased bg-background text-foreground`}
|
| 45 |
>
|
| 46 |
{children}
|
| 47 |
+
<Toaster richColors position="bottom-right" />
|
| 48 |
</body>
|
| 49 |
</html>
|
| 50 |
);
|
src/app/page.tsx
CHANGED
|
@@ -1,119 +1,42 @@
|
|
| 1 |
"use client";
|
| 2 |
|
| 3 |
import { useState, useEffect, useCallback } from "react";
|
| 4 |
-
import {
|
| 5 |
-
import {
|
| 6 |
-
Menu, X, Bot, Wand2, ImageIcon, Video, Users, FolderGit2, Shield,
|
| 7 |
-
RefreshCw, DollarSign, Clock, Film, Zap, TrendingUp, Calendar,
|
| 8 |
-
Play, Pause, Trash2, Plus, Send, Copy, Eye, Settings, ChevronRight,
|
| 9 |
-
CheckCircle2, AlertCircle, ExternalLink, CreditCard, Target, Sparkles,
|
| 10 |
-
Heart, PawPrint, Star, Users2, Lightbulb, Flame, Rocket
|
| 11 |
-
} from "lucide-react";
|
| 12 |
-
|
| 13 |
import { Button } from "@/components/ui/button";
|
| 14 |
-
import { Input } from "@/components/ui/input";
|
| 15 |
-
import { Textarea } from "@/components/ui/textarea";
|
| 16 |
-
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
| 17 |
-
import { Badge } from "@/components/ui/badge";
|
| 18 |
-
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
| 19 |
-
import { ScrollArea } from "@/components/ui/scroll-area";
|
| 20 |
-
import { Label } from "@/components/ui/label";
|
| 21 |
-
import { Switch } from "@/components/ui/switch";
|
| 22 |
import { toast } from "sonner";
|
| 23 |
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
return response.json();
|
| 41 |
-
}
|
| 42 |
|
| 43 |
export default function Dashboard() {
|
| 44 |
const [sidebarOpen, setSidebarOpen] = useState(true);
|
| 45 |
const [activeTab, setActiveTab] = useState("prompt-engineer");
|
| 46 |
const [loading, setLoading] = useState(false);
|
| 47 |
|
| 48 |
-
//
|
| 49 |
-
const [
|
| 50 |
-
const [promptType, setPromptType] = useState("image");
|
| 51 |
-
const [targetPlatform, setTargetPlatform] = useState("general");
|
| 52 |
-
const [optimizedPrompt, setOptimizedPrompt] = useState<any | null>(null);
|
| 53 |
-
const [promptLoading, setPromptLoading] = useState(false);
|
| 54 |
-
|
| 55 |
-
// Image Generation
|
| 56 |
-
const [imagePrompt, setImagePrompt] = useState("");
|
| 57 |
-
const [imageStyle, setImageStyle] = useState("realistic");
|
| 58 |
-
const [imagePlatform, setImagePlatform] = useState("general");
|
| 59 |
-
const [imageLoading, setImageLoading] = useState(false);
|
| 60 |
-
|
| 61 |
-
// Monetization
|
| 62 |
const [platforms, setPlatforms] = useState<Platform[]>([]);
|
| 63 |
-
const [selectedMonetizationPlatform, setSelectedMonetizationPlatform] = useState("");
|
| 64 |
-
|
| 65 |
-
// Posts
|
| 66 |
const [posts, setPosts] = useState<Post[]>([]);
|
| 67 |
-
const [postTitle, setPostTitle] = useState("");
|
| 68 |
-
const [postType, setPostType] = useState("reel");
|
| 69 |
-
const [postCaption, setPostCaption] = useState("");
|
| 70 |
-
const [scheduledTime, setScheduledTime] = useState("");
|
| 71 |
-
|
| 72 |
-
// Stories
|
| 73 |
const [stories, setStories] = useState<Story[]>([]);
|
| 74 |
-
const [storyPrompt, setStoryPrompt] = useState("");
|
| 75 |
-
const [storyGenre, setStoryGenre] = useState("lifestyle");
|
| 76 |
-
const [storyEpisodes, setStoryEpisodes] = useState(7);
|
| 77 |
-
const [storyLoading, setStoryLoading] = useState(false);
|
| 78 |
-
|
| 79 |
-
// Automation
|
| 80 |
const [automations, setAutomations] = useState<Automation[]>([]);
|
| 81 |
-
const [automationName, setAutomationName] = useState("");
|
| 82 |
-
const [automationType, setAutomationType] = useState("content_generation");
|
| 83 |
-
|
| 84 |
-
// Content & Characters
|
| 85 |
-
const [contents, setContents] = useState<Content[]>([]);
|
| 86 |
-
const [characters, setCharacters] = useState<Character[]>([]);
|
| 87 |
-
|
| 88 |
-
// Pets
|
| 89 |
const [pets, setPets] = useState<Pet[]>([]);
|
| 90 |
-
const [petName, setPetName] = useState("");
|
| 91 |
-
const [petType, setPetType] = useState("dog");
|
| 92 |
-
const [petBreed, setPetBreed] = useState("");
|
| 93 |
-
const [petPersonality, setPetPersonality] = useState("");
|
| 94 |
const [includePetInContent, setIncludePetInContent] = useState(false);
|
| 95 |
-
const [petLoading, setPetLoading] = useState(false);
|
| 96 |
-
|
| 97 |
-
// Influencers
|
| 98 |
-
const [influencers, setInfluencers] = useState<Influencer[]>([]);
|
| 99 |
-
const [influencerNiche, setInfluencerNiche] = useState("");
|
| 100 |
-
const [influencerPlatform, setInfluencerPlatform] = useState("");
|
| 101 |
-
const [influencerWithPets, setInfluencerWithPets] = useState(false);
|
| 102 |
-
const [influencerLoading, setInfluencerLoading] = useState(false);
|
| 103 |
-
const [influencerAnalysis, setInfluencerAnalysis] = useState<any | null>(null);
|
| 104 |
-
const [characterConcept, setCharacterConcept] = useState<any | null>(null);
|
| 105 |
-
|
| 106 |
-
// Trends
|
| 107 |
-
const [trends, setTrends] = useState<any[]>([]);
|
| 108 |
-
const [viralStrategies, setViralStrategies] = useState<any[]>([]);
|
| 109 |
-
const [contentIdeas, setContentIdeas] = useState<any[]>([]);
|
| 110 |
-
const [trendAnalysis, setTrendAnalysis] = useState<any | null>(null);
|
| 111 |
-
const [trendLoading, setTrendLoading] = useState(false);
|
| 112 |
-
|
| 113 |
-
// Stats
|
| 114 |
const [stats, setStats] = useState({ images: 0, videos: 0, stories: 0, automations: 0, pets: 0 });
|
| 115 |
|
| 116 |
-
// Load data
|
| 117 |
const loadData = useCallback(async () => {
|
| 118 |
setLoading(true);
|
| 119 |
try {
|
|
@@ -150,979 +73,58 @@ export default function Dashboard() {
|
|
| 150 |
|
| 151 |
useEffect(() => { loadData(); }, [loadData]);
|
| 152 |
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
style: imageStyle,
|
| 181 |
-
includePet: includePetInContent,
|
| 182 |
-
petId: pets[0]?.id
|
| 183 |
-
}),
|
| 184 |
-
});
|
| 185 |
-
if (result.success) {
|
| 186 |
-
toast.success("Imagen generada");
|
| 187 |
-
loadData();
|
| 188 |
-
} else toast.error(result.error);
|
| 189 |
-
} catch { toast.error("Error"); }
|
| 190 |
-
finally { setImageLoading(false); }
|
| 191 |
-
};
|
| 192 |
-
|
| 193 |
-
const handleCreateStory = async () => {
|
| 194 |
-
if (!storyPrompt.trim()) { toast.error("Describe tu historia"); return; }
|
| 195 |
-
setStoryLoading(true);
|
| 196 |
-
try {
|
| 197 |
-
const result = await apiFetch("/storytelling", {
|
| 198 |
-
method: "POST",
|
| 199 |
-
body: JSON.stringify({
|
| 200 |
-
prompt: storyPrompt,
|
| 201 |
-
genre: storyGenre,
|
| 202 |
-
totalEpisodes: storyEpisodes,
|
| 203 |
-
}),
|
| 204 |
-
});
|
| 205 |
-
if (result.success) {
|
| 206 |
-
toast.success(`Historia "${result.story?.title}" creada`);
|
| 207 |
-
setStoryPrompt("");
|
| 208 |
-
loadData();
|
| 209 |
-
} else toast.error(result.error);
|
| 210 |
-
} catch { toast.error("Error"); }
|
| 211 |
-
finally { setStoryLoading(false); }
|
| 212 |
-
};
|
| 213 |
-
|
| 214 |
-
const handleCreatePost = async () => {
|
| 215 |
-
if (!postTitle.trim()) { toast.error("Añade un título"); return; }
|
| 216 |
-
try {
|
| 217 |
-
const result = await apiFetch("/posts", {
|
| 218 |
-
method: "POST",
|
| 219 |
-
body: JSON.stringify({
|
| 220 |
-
title: postTitle,
|
| 221 |
-
type: postType,
|
| 222 |
-
caption: postCaption,
|
| 223 |
-
scheduledAt: scheduledTime || null,
|
| 224 |
-
autoGenerateCaption: true,
|
| 225 |
-
}),
|
| 226 |
-
});
|
| 227 |
-
if (result.success) {
|
| 228 |
-
toast.success(scheduledTime ? "Post programado" : "Post creado");
|
| 229 |
-
setPostTitle(""); setPostCaption(""); setScheduledTime("");
|
| 230 |
-
loadData();
|
| 231 |
-
} else toast.error(result.error);
|
| 232 |
-
} catch { toast.error("Error"); }
|
| 233 |
-
};
|
| 234 |
-
|
| 235 |
-
const handleCreateAutomation = async () => {
|
| 236 |
-
if (!automationName.trim()) { toast.error("Dale un nombre"); return; }
|
| 237 |
-
try {
|
| 238 |
-
const result = await apiFetch("/automation", {
|
| 239 |
-
method: "POST",
|
| 240 |
-
body: JSON.stringify({
|
| 241 |
-
name: automationName,
|
| 242 |
-
type: automationType,
|
| 243 |
-
trigger: "schedule",
|
| 244 |
-
triggerConfig: { schedule: "daily:09:00" },
|
| 245 |
-
actions: [{ type: "generate_content", prompt: "Genera contenido de lifestyle" }],
|
| 246 |
-
}),
|
| 247 |
-
});
|
| 248 |
-
if (result.success) {
|
| 249 |
-
toast.success("Automatización creada");
|
| 250 |
-
setAutomationName("");
|
| 251 |
-
loadData();
|
| 252 |
-
} else toast.error(result.error);
|
| 253 |
-
} catch { toast.error("Error"); }
|
| 254 |
-
};
|
| 255 |
-
|
| 256 |
-
const handleToggleAutomation = async (id: string, currentStatus: boolean) => {
|
| 257 |
-
try {
|
| 258 |
-
await apiFetch("/automation", {
|
| 259 |
-
method: "PUT",
|
| 260 |
-
body: JSON.stringify({ id, isActive: !currentStatus }),
|
| 261 |
-
});
|
| 262 |
-
loadData();
|
| 263 |
-
} catch { toast.error("Error"); }
|
| 264 |
-
};
|
| 265 |
-
|
| 266 |
-
const handleCreatePet = async () => {
|
| 267 |
-
if (!petName.trim()) { toast.error("Dale un nombre a tu mascota"); return; }
|
| 268 |
-
setPetLoading(true);
|
| 269 |
-
try {
|
| 270 |
-
const result = await apiFetch("/pets", {
|
| 271 |
-
method: "POST",
|
| 272 |
-
body: JSON.stringify({
|
| 273 |
-
name: petName,
|
| 274 |
-
type: petType,
|
| 275 |
-
breed: petBreed || undefined,
|
| 276 |
-
personality: petPersonality || undefined,
|
| 277 |
-
generateReference: true
|
| 278 |
-
}),
|
| 279 |
-
});
|
| 280 |
-
if (result.success) {
|
| 281 |
-
toast.success(`Mascota "${petName}" creada (+${result.engagementBoost}% engagement)`);
|
| 282 |
-
setPetName(""); setPetBreed(""); setPetPersonality("");
|
| 283 |
-
loadData();
|
| 284 |
-
} else toast.error(result.error);
|
| 285 |
-
} catch { toast.error("Error"); }
|
| 286 |
-
finally { setPetLoading(false); }
|
| 287 |
-
};
|
| 288 |
-
|
| 289 |
-
const handleAnalyzeInfluencers = async () => {
|
| 290 |
-
setInfluencerLoading(true);
|
| 291 |
-
try {
|
| 292 |
-
const result = await apiFetch("/influencers", {
|
| 293 |
-
method: "POST",
|
| 294 |
-
body: JSON.stringify({
|
| 295 |
-
targetNiche: influencerNiche || "lifestyle",
|
| 296 |
-
targetPlatform: influencerPlatform || undefined,
|
| 297 |
-
includePets: influencerWithPets
|
| 298 |
-
}),
|
| 299 |
-
});
|
| 300 |
-
if (result.success) {
|
| 301 |
-
setInfluencers(result.referenceInfluencers || []);
|
| 302 |
-
setInfluencerAnalysis(result.analysis);
|
| 303 |
-
setCharacterConcept(result.characterConcept);
|
| 304 |
-
toast.success("Análisis completado");
|
| 305 |
-
} else toast.error(result.error);
|
| 306 |
-
} catch { toast.error("Error"); }
|
| 307 |
-
finally { setInfluencerLoading(false); }
|
| 308 |
-
};
|
| 309 |
-
|
| 310 |
-
const handleAnalyzeTrends = async () => {
|
| 311 |
-
setTrendLoading(true);
|
| 312 |
-
try {
|
| 313 |
-
const result = await apiFetch("/trends", {
|
| 314 |
-
method: "POST",
|
| 315 |
-
body: JSON.stringify({
|
| 316 |
-
niche: influencerNiche || "lifestyle",
|
| 317 |
-
platform: influencerPlatform || undefined,
|
| 318 |
-
includePets: includePetInContent,
|
| 319 |
-
daysToViral: 14
|
| 320 |
-
}),
|
| 321 |
-
});
|
| 322 |
-
if (result.success) {
|
| 323 |
-
setTrendAnalysis(result.analysis);
|
| 324 |
-
toast.success("Análisis de tendencias completado");
|
| 325 |
-
} else toast.error(result.error);
|
| 326 |
-
} catch { toast.error("Error"); }
|
| 327 |
-
finally { setTrendLoading(false); }
|
| 328 |
-
};
|
| 329 |
-
|
| 330 |
-
const loadTrends = async () => {
|
| 331 |
-
try {
|
| 332 |
-
const result = await apiFetch(`/trends?includePets=${includePetInContent}`);
|
| 333 |
-
if (result.success) {
|
| 334 |
-
setTrends(result.trends);
|
| 335 |
-
setViralStrategies(result.viralStrategies);
|
| 336 |
-
setContentIdeas(result.contentIdeas);
|
| 337 |
-
}
|
| 338 |
-
} catch { toast.error("Error cargando tendencias"); }
|
| 339 |
-
};
|
| 340 |
-
|
| 341 |
-
useEffect(() => {
|
| 342 |
-
if (activeTab === "trends") loadTrends();
|
| 343 |
-
}, [activeTab, includePetInContent]);
|
| 344 |
-
|
| 345 |
-
const copyToClipboard = (text: string) => {
|
| 346 |
-
navigator.clipboard.writeText(text);
|
| 347 |
-
toast.success("Copiado");
|
| 348 |
-
};
|
| 349 |
-
|
| 350 |
-
const getStatusColor = (status: string) => {
|
| 351 |
-
const colors: Record<string, string> = {
|
| 352 |
-
completed: "bg-green-500/10 text-green-500",
|
| 353 |
-
published: "bg-green-500/10 text-green-500",
|
| 354 |
-
scheduled: "bg-blue-500/10 text-blue-500",
|
| 355 |
-
draft: "bg-slate-500/10 text-slate-400",
|
| 356 |
-
pending: "bg-yellow-500/10 text-yellow-500",
|
| 357 |
-
active: "bg-green-500/10 text-green-500",
|
| 358 |
-
};
|
| 359 |
-
return colors[status] || "bg-slate-500/10 text-slate-400";
|
| 360 |
};
|
| 361 |
|
| 362 |
-
const petTypes = [
|
| 363 |
-
{ value: "dog", label: "🐕 Perro" },
|
| 364 |
-
{ value: "cat", label: "🐱 Gato" },
|
| 365 |
-
{ value: "bird", label: "🐦 Pájaro" },
|
| 366 |
-
{ value: "rabbit", label: "🐰 Conejo" },
|
| 367 |
-
{ value: "hamster", label: "🐹 Hámster" }
|
| 368 |
-
];
|
| 369 |
-
|
| 370 |
return (
|
| 371 |
-
<div className="min-h-screen bg-gradient-to-
|
| 372 |
-
{
|
| 373 |
-
<AnimatePresence mode="wait">
|
| 374 |
-
{sidebarOpen && (
|
| 375 |
-
<motion.aside
|
| 376 |
-
initial={{ x: -280 }} animate={{ x: 0 }} exit={{ x: -280 }}
|
| 377 |
-
className="fixed left-0 top-0 h-full w-64 bg-slate-900/90 backdrop-blur-xl border-r border-slate-800 z-50"
|
| 378 |
-
>
|
| 379 |
-
<div className="p-5">
|
| 380 |
-
<div className="flex items-center gap-3 mb-6">
|
| 381 |
-
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-violet-500 to-purple-600 flex items-center justify-center">
|
| 382 |
-
<Bot className="h-5 w-5" />
|
| 383 |
-
</div>
|
| 384 |
-
<div>
|
| 385 |
-
<h1 className="text-lg font-bold bg-gradient-to-r from-violet-400 to-purple-400 bg-clip-text text-transparent">AI Influencer Studio</h1>
|
| 386 |
-
<p className="text-xs text-slate-400">Plataforma SaaS</p>
|
| 387 |
-
</div>
|
| 388 |
-
</div>
|
| 389 |
-
|
| 390 |
-
<nav className="space-y-1">
|
| 391 |
-
{[
|
| 392 |
-
{ id: "prompt-engineer", icon: Wand2, label: "Ingeniero IA" },
|
| 393 |
-
{ id: "images", icon: ImageIcon, label: "Imágenes" },
|
| 394 |
-
{ id: "videos", icon: Video, label: "Videos" },
|
| 395 |
-
{ id: "monetization", icon: DollarSign, label: "Monetización" },
|
| 396 |
-
{ id: "posts", icon: Calendar, label: "Publicaciones" },
|
| 397 |
-
{ id: "storytelling", icon: Film, label: "Storytelling" },
|
| 398 |
-
{ id: "automation", icon: Zap, label: "Automatización" },
|
| 399 |
-
{ id: "influencers", icon: Users2, label: "Influencers IA" },
|
| 400 |
-
{ id: "pets", icon: PawPrint, label: "Mascotas" },
|
| 401 |
-
{ id: "trends", icon: TrendingUp, label: "Tendencias" },
|
| 402 |
-
{ id: "content", icon: FolderGit2, label: "Contenido" },
|
| 403 |
-
].map((item) => (
|
| 404 |
-
<button key={item.id} onClick={() => setActiveTab(item.id)}
|
| 405 |
-
className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-lg transition-all text-sm ${activeTab === item.id ? "bg-violet-500/20 text-violet-400 border border-violet-500/30" : "text-slate-400 hover:bg-slate-800"}`}>
|
| 406 |
-
<item.icon className="h-4 w-4" /><span>{item.label}</span>
|
| 407 |
-
</button>
|
| 408 |
-
))}
|
| 409 |
-
</nav>
|
| 410 |
-
</div>
|
| 411 |
-
|
| 412 |
-
<div className="absolute bottom-0 left-0 right-0 p-4 border-t border-slate-800">
|
| 413 |
-
<div className="grid grid-cols-2 gap-2 text-center text-xs">
|
| 414 |
-
<div><p className="text-xl font-bold text-violet-400">{stats.images}</p><p className="text-slate-500">Imágenes</p></div>
|
| 415 |
-
<div><p className="text-xl font-bold text-blue-400">{stats.videos}</p><p className="text-slate-500">Videos</p></div>
|
| 416 |
-
<div><p className="text-xl font-bold text-green-400">{stats.stories}</p><p className="text-slate-500">Historias</p></div>
|
| 417 |
-
<div><p className="text-xl font-bold text-amber-400">{stats.pets}</p><p className="text-slate-500">Mascotas</p></div>
|
| 418 |
-
</div>
|
| 419 |
-
</div>
|
| 420 |
-
</motion.aside>
|
| 421 |
-
)}
|
| 422 |
-
</AnimatePresence>
|
| 423 |
|
| 424 |
-
{/* Main */}
|
| 425 |
<div className={`transition-all duration-300 ${sidebarOpen ? "ml-64" : "ml-0"}`}>
|
| 426 |
-
|
|
|
|
| 427 |
<div className="flex items-center justify-between px-6 py-3">
|
| 428 |
<div className="flex items-center gap-3">
|
| 429 |
-
<Button variant="ghost" size="icon" onClick={() => setSidebarOpen(!sidebarOpen)}
|
| 430 |
{sidebarOpen ? <X className="h-5 w-5" /> : <Menu className="h-5 w-5" />}
|
| 431 |
</Button>
|
| 432 |
-
<h2 className="text-lg font-semibold capitalize">{activeTab.replace(
|
| 433 |
</div>
|
| 434 |
-
<Button variant="
|
| 435 |
<RefreshCw className={`h-4 w-4 mr-2 ${loading ? "animate-spin" : ""}`} />Actualizar
|
| 436 |
</Button>
|
| 437 |
</div>
|
| 438 |
</header>
|
| 439 |
|
|
|
|
| 440 |
<main className="p-6">
|
| 441 |
-
{
|
| 442 |
-
{activeTab === "prompt-engineer" && (
|
| 443 |
-
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
| 444 |
-
<Card className="bg-slate-900/50 border-slate-800">
|
| 445 |
-
<CardHeader>
|
| 446 |
-
<CardTitle className="flex items-center gap-2"><Wand2 className="h-5 w-5 text-violet-400" />Ingeniero de Prompts</CardTitle>
|
| 447 |
-
<CardDescription>Describe en lenguaje natural lo que quieres crear</CardDescription>
|
| 448 |
-
</CardHeader>
|
| 449 |
-
<CardContent className="space-y-4">
|
| 450 |
-
<Textarea placeholder="Ej: Quiero fotos de una mujer rubia en la playa para OnlyFans..." value={userPrompt} onChange={(e) => setUserPrompt(e.target.value)} className="bg-slate-800 border-slate-700 min-h-28" />
|
| 451 |
-
<div className="grid grid-cols-2 gap-4">
|
| 452 |
-
<div>
|
| 453 |
-
<Label className="text-xs text-slate-400">Tipo</Label>
|
| 454 |
-
<Select value={promptType} onValueChange={setPromptType}>
|
| 455 |
-
<SelectTrigger className="bg-slate-800 border-slate-700 mt-1"><SelectValue /></SelectTrigger>
|
| 456 |
-
<SelectContent>
|
| 457 |
-
<SelectItem value="image">Imagen</SelectItem>
|
| 458 |
-
<SelectItem value="video">Video</SelectItem>
|
| 459 |
-
<SelectItem value="reel">Reel</SelectItem>
|
| 460 |
-
<SelectItem value="carousel">Carrusel</SelectItem>
|
| 461 |
-
</SelectContent>
|
| 462 |
-
</Select>
|
| 463 |
-
</div>
|
| 464 |
-
<div>
|
| 465 |
-
<Label className="text-xs text-slate-400">Plataforma</Label>
|
| 466 |
-
<Select value={targetPlatform} onValueChange={setTargetPlatform}>
|
| 467 |
-
<SelectTrigger className="bg-slate-800 border-slate-700 mt-1"><SelectValue /></SelectTrigger>
|
| 468 |
-
<SelectContent>
|
| 469 |
-
<SelectItem value="general">General</SelectItem>
|
| 470 |
-
<SelectItem value="onlyfans">OnlyFans</SelectItem>
|
| 471 |
-
<SelectItem value="patreon">Patreon</SelectItem>
|
| 472 |
-
<SelectItem value="instagram">Instagram</SelectItem>
|
| 473 |
-
<SelectItem value="tiktok">TikTok</SelectItem>
|
| 474 |
-
<SelectItem value="youtube">YouTube</SelectItem>
|
| 475 |
-
</SelectContent>
|
| 476 |
-
</Select>
|
| 477 |
-
</div>
|
| 478 |
-
</div>
|
| 479 |
-
{pets.length > 0 && (
|
| 480 |
-
<div className="flex items-center gap-2">
|
| 481 |
-
<Switch checked={includePetInContent} onCheckedChange={setIncludePetInContent} />
|
| 482 |
-
<Label className="text-sm text-slate-400">Incluir mascota en el contenido (+35% engagement)</Label>
|
| 483 |
-
</div>
|
| 484 |
-
)}
|
| 485 |
-
<Button onClick={handleOptimizePrompt} disabled={promptLoading} className="w-full bg-violet-600 hover:bg-violet-700">
|
| 486 |
-
{promptLoading ? <RefreshCw className="h-4 w-4 mr-2 animate-spin" /> : <Wand2 className="h-4 w-4 mr-2" />}Optimizar
|
| 487 |
-
</Button>
|
| 488 |
-
</CardContent>
|
| 489 |
-
</Card>
|
| 490 |
-
|
| 491 |
-
<Card className="bg-slate-900/50 border-slate-800">
|
| 492 |
-
<CardHeader><CardTitle>Prompt Optimizado</CardTitle></CardHeader>
|
| 493 |
-
<CardContent>
|
| 494 |
-
{optimizedPrompt ? (
|
| 495 |
-
<div className="space-y-3">
|
| 496 |
-
<div className="p-3 rounded-lg bg-slate-800/50 border border-slate-700">
|
| 497 |
-
<div className="flex justify-between mb-2">
|
| 498 |
-
<Badge className="bg-violet-500/20 text-violet-400">{String(optimizedPrompt.type)}</Badge>
|
| 499 |
-
<Button variant="ghost" size="sm" onClick={() => copyToClipboard(String(optimizedPrompt.optimizedPrompt))}><Copy className="h-4 w-4" /></Button>
|
| 500 |
-
</div>
|
| 501 |
-
<p className="text-sm whitespace-pre-wrap">{String(optimizedPrompt.optimizedPrompt)}</p>
|
| 502 |
-
</div>
|
| 503 |
-
<div className="flex gap-2">
|
| 504 |
-
<Button onClick={handleGenerateImage} disabled={imageLoading} className="flex-1 bg-green-600 hover:bg-green-700">
|
| 505 |
-
<ImageIcon className="h-4 w-4 mr-2" />Generar Imagen
|
| 506 |
-
</Button>
|
| 507 |
-
<Button onClick={() => setActiveTab("posts")} className="flex-1 bg-blue-600 hover:bg-blue-700">
|
| 508 |
-
<Calendar className="h-4 w-4 mr-2" />Crear Post
|
| 509 |
-
</Button>
|
| 510 |
-
</div>
|
| 511 |
-
</div>
|
| 512 |
-
) : (
|
| 513 |
-
<div className="text-center py-12 text-slate-400">
|
| 514 |
-
<Wand2 className="h-12 w-12 mx-auto mb-3 opacity-50" /><p>El prompt optimizado aparecerá aquí</p>
|
| 515 |
-
</div>
|
| 516 |
-
)}
|
| 517 |
-
</CardContent>
|
| 518 |
-
</Card>
|
| 519 |
-
</div>
|
| 520 |
-
)}
|
| 521 |
-
|
| 522 |
-
{/* Images */}
|
| 523 |
-
{activeTab === "images" && (
|
| 524 |
-
<Card className="bg-slate-900/50 border-slate-800">
|
| 525 |
-
<CardHeader><CardTitle className="flex items-center gap-2"><ImageIcon className="h-5 w-5 text-green-400" />Generar Imágenes</CardTitle></CardHeader>
|
| 526 |
-
<CardContent className="space-y-4">
|
| 527 |
-
<Textarea placeholder="Describe la imagen..." value={userPrompt} onChange={(e) => setUserPrompt(e.target.value)} className="bg-slate-800 border-slate-700" />
|
| 528 |
-
<div className="grid grid-cols-3 gap-4">
|
| 529 |
-
<div>
|
| 530 |
-
<Label className="text-xs text-slate-400">Estilo</Label>
|
| 531 |
-
<Select value={imageStyle} onValueChange={setImageStyle}>
|
| 532 |
-
<SelectTrigger className="bg-slate-800 border-slate-700 mt-1"><SelectValue /></SelectTrigger>
|
| 533 |
-
<SelectContent>
|
| 534 |
-
<SelectItem value="realistic">Realista</SelectItem>
|
| 535 |
-
<SelectItem value="anime">Anime</SelectItem>
|
| 536 |
-
<SelectItem value="artistic">Artístico</SelectItem>
|
| 537 |
-
</SelectContent>
|
| 538 |
-
</Select>
|
| 539 |
-
</div>
|
| 540 |
-
<div>
|
| 541 |
-
<Label className="text-xs text-slate-400">Plataforma</Label>
|
| 542 |
-
<Select value={imagePlatform} onValueChange={setImagePlatform}>
|
| 543 |
-
<SelectTrigger className="bg-slate-800 border-slate-700 mt-1"><SelectValue /></SelectTrigger>
|
| 544 |
-
<SelectContent>
|
| 545 |
-
<SelectItem value="general">General</SelectItem>
|
| 546 |
-
<SelectItem value="onlyfans">OnlyFans</SelectItem>
|
| 547 |
-
<SelectItem value="instagram">Instagram</SelectItem>
|
| 548 |
-
</SelectContent>
|
| 549 |
-
</Select>
|
| 550 |
-
</div>
|
| 551 |
-
</div>
|
| 552 |
-
{pets.length > 0 && (
|
| 553 |
-
<div className="flex items-center gap-2">
|
| 554 |
-
<Switch checked={includePetInContent} onCheckedChange={setIncludePetInContent} />
|
| 555 |
-
<Label className="text-sm text-slate-400">Incluir mascota ({pets[0]?.name})</Label>
|
| 556 |
-
</div>
|
| 557 |
-
)}
|
| 558 |
-
<Button onClick={handleGenerateImage} disabled={imageLoading} className="w-full bg-green-600 hover:bg-green-700">
|
| 559 |
-
{imageLoading ? <RefreshCw className="h-4 w-4 mr-2 animate-spin" /> : <ImageIcon className="h-4 w-4 mr-2" />}Generar
|
| 560 |
-
</Button>
|
| 561 |
-
</CardContent>
|
| 562 |
-
</Card>
|
| 563 |
-
)}
|
| 564 |
-
|
| 565 |
-
{/* Monetization */}
|
| 566 |
-
{activeTab === "monetization" && (
|
| 567 |
-
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
| 568 |
-
{[
|
| 569 |
-
{ name: "OnlyFans", type: "subscription", fee: "20%", color: "from-blue-500 to-cyan-500", legal: "Adult content permitido" },
|
| 570 |
-
{ name: "Patreon", type: "subscription", fee: "12%", color: "from-orange-500 to-red-500", legal: "Sin adult content real" },
|
| 571 |
-
{ name: "Fansly", type: "mixed", fee: "20%", color: "from-purple-500 to-pink-500", legal: "Adult content permitido" },
|
| 572 |
-
{ name: "Fanvue", type: "subscription", fee: "15%", color: "from-green-500 to-teal-500", legal: "Adult content permitido" },
|
| 573 |
-
{ name: "Ko-fi", type: "tips", fee: "0%", color: "from-sky-500 to-blue-500", legal: "Sin adult content" },
|
| 574 |
-
{ name: "Instagram", type: "free", fee: "0%", color: "from-pink-500 to-purple-500", legal: "Sin desnudez" },
|
| 575 |
-
].map((p) => (
|
| 576 |
-
<Card key={p.name} className="bg-slate-900/50 border-slate-800">
|
| 577 |
-
<CardContent className="p-4">
|
| 578 |
-
<div className={`w-10 h-10 rounded-lg bg-gradient-to-br ${p.color} flex items-center justify-center mb-3`}>
|
| 579 |
-
<DollarSign className="h-5 w-5 text-white" />
|
| 580 |
-
</div>
|
| 581 |
-
<h3 className="font-semibold">{p.name}</h3>
|
| 582 |
-
<p className="text-xs text-slate-400 mt-1">Fee: {p.fee}</p>
|
| 583 |
-
<Badge variant="outline" className="mt-2 text-xs">{p.type}</Badge>
|
| 584 |
-
<p className="text-xs text-amber-400 mt-2">⚖️ {p.legal}</p>
|
| 585 |
-
<Button size="sm" className="w-full mt-3 bg-violet-600 hover:bg-violet-700">
|
| 586 |
-
<Plus className="h-3 w-3 mr-1" />Conectar
|
| 587 |
-
</Button>
|
| 588 |
-
</CardContent>
|
| 589 |
-
</Card>
|
| 590 |
-
))}
|
| 591 |
-
</div>
|
| 592 |
-
)}
|
| 593 |
-
|
| 594 |
-
{/* Posts */}
|
| 595 |
-
{activeTab === "posts" && (
|
| 596 |
-
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
| 597 |
-
<Card className="bg-slate-900/50 border-slate-800">
|
| 598 |
-
<CardHeader><CardTitle className="flex items-center gap-2"><Calendar className="h-5 w-5 text-blue-400" />Crear Publicación</CardTitle></CardHeader>
|
| 599 |
-
<CardContent className="space-y-4">
|
| 600 |
-
<Input placeholder="Título" value={postTitle} onChange={(e) => setPostTitle(e.target.value)} className="bg-slate-800 border-slate-700" />
|
| 601 |
-
<Textarea placeholder="Caption..." value={postCaption} onChange={(e) => setPostCaption(e.target.value)} className="bg-slate-800 border-slate-700 min-h-20" />
|
| 602 |
-
<div className="grid grid-cols-2 gap-4">
|
| 603 |
-
<div>
|
| 604 |
-
<Label className="text-xs text-slate-400">Tipo</Label>
|
| 605 |
-
<Select value={postType} onValueChange={setPostType}>
|
| 606 |
-
<SelectTrigger className="bg-slate-800 border-slate-700 mt-1"><SelectValue /></SelectTrigger>
|
| 607 |
-
<SelectContent>
|
| 608 |
-
<SelectItem value="reel">Reel</SelectItem>
|
| 609 |
-
<SelectItem value="photo">Foto</SelectItem>
|
| 610 |
-
<SelectItem value="carousel">Carrusel</SelectItem>
|
| 611 |
-
<SelectItem value="story">Story</SelectItem>
|
| 612 |
-
</SelectContent>
|
| 613 |
-
</Select>
|
| 614 |
-
</div>
|
| 615 |
-
<div>
|
| 616 |
-
<Label className="text-xs text-slate-400">Programar</Label>
|
| 617 |
-
<Input type="datetime-local" value={scheduledTime} onChange={(e) => setScheduledTime(e.target.value)} className="bg-slate-800 border-slate-700 mt-1" />
|
| 618 |
-
</div>
|
| 619 |
-
</div>
|
| 620 |
-
<Button onClick={handleCreatePost} className="w-full bg-blue-600 hover:bg-blue-700">
|
| 621 |
-
<Calendar className="h-4 w-4 mr-2" />{scheduledTime ? "Programar" : "Crear"}
|
| 622 |
-
</Button>
|
| 623 |
-
</CardContent>
|
| 624 |
-
</Card>
|
| 625 |
-
|
| 626 |
-
<Card className="bg-slate-900/50 border-slate-800">
|
| 627 |
-
<CardHeader><CardTitle>Publicaciones</CardTitle></CardHeader>
|
| 628 |
-
<CardContent>
|
| 629 |
-
<ScrollArea className="h-64">
|
| 630 |
-
{posts.length === 0 ? (
|
| 631 |
-
<p className="text-slate-400 text-center py-8">No hay publicaciones</p>
|
| 632 |
-
) : (
|
| 633 |
-
<div className="space-y-2">
|
| 634 |
-
{posts.map((p) => (
|
| 635 |
-
<div key={p.id} className="p-3 rounded-lg bg-slate-800/50 flex items-center justify-between">
|
| 636 |
-
<div>
|
| 637 |
-
<p className="font-medium text-sm">{p.title || "Sin título"}</p>
|
| 638 |
-
<p className="text-xs text-slate-400">{p.type} • {p.scheduledAt ? new Date(p.scheduledAt).toLocaleString() : "Borrador"}</p>
|
| 639 |
-
</div>
|
| 640 |
-
<Badge className={getStatusColor(p.status)}>{p.status}</Badge>
|
| 641 |
-
</div>
|
| 642 |
-
))}
|
| 643 |
-
</div>
|
| 644 |
-
)}
|
| 645 |
-
</ScrollArea>
|
| 646 |
-
</CardContent>
|
| 647 |
-
</Card>
|
| 648 |
-
</div>
|
| 649 |
-
)}
|
| 650 |
-
|
| 651 |
-
{/* Storytelling */}
|
| 652 |
-
{activeTab === "storytelling" && (
|
| 653 |
-
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
| 654 |
-
<Card className="bg-slate-900/50 border-slate-800">
|
| 655 |
-
<CardHeader><CardTitle className="flex items-center gap-2"><Film className="h-5 w-5 text-purple-400" />Crear Historia</CardTitle></CardHeader>
|
| 656 |
-
<CardContent className="space-y-4">
|
| 657 |
-
<Textarea placeholder="Describe tu historia... Ej: Una historia de transformación fitness de 30 días..." value={storyPrompt} onChange={(e) => setStoryPrompt(e.target.value)} className="bg-slate-800 border-slate-700 min-h-24" />
|
| 658 |
-
<div className="grid grid-cols-2 gap-4">
|
| 659 |
-
<div>
|
| 660 |
-
<Label className="text-xs text-slate-400">Género</Label>
|
| 661 |
-
<Select value={storyGenre} onValueChange={setStoryGenre}>
|
| 662 |
-
<SelectTrigger className="bg-slate-800 border-slate-700 mt-1"><SelectValue /></SelectTrigger>
|
| 663 |
-
<SelectContent>
|
| 664 |
-
<SelectItem value="lifestyle">Lifestyle</SelectItem>
|
| 665 |
-
<SelectItem value="fitness">Fitness</SelectItem>
|
| 666 |
-
<SelectItem value="romance">Romance</SelectItem>
|
| 667 |
-
<SelectItem value="drama">Drama</SelectItem>
|
| 668 |
-
<SelectItem value="comedy">Comedia</SelectItem>
|
| 669 |
-
</SelectContent>
|
| 670 |
-
</Select>
|
| 671 |
-
</div>
|
| 672 |
-
<div>
|
| 673 |
-
<Label className="text-xs text-slate-400">Episodios</Label>
|
| 674 |
-
<Input type="number" value={storyEpisodes} onChange={(e) => setStoryEpisodes(parseInt(e.target.value) || 7)} className="bg-slate-800 border-slate-700 mt-1" />
|
| 675 |
-
</div>
|
| 676 |
-
</div>
|
| 677 |
-
<Button onClick={handleCreateStory} disabled={storyLoading} className="w-full bg-purple-600 hover:bg-purple-700">
|
| 678 |
-
{storyLoading ? <RefreshCw className="h-4 w-4 mr-2 animate-spin" /> : <Sparkles className="h-4 w-4 mr-2" />}Generar Historia
|
| 679 |
-
</Button>
|
| 680 |
-
</CardContent>
|
| 681 |
-
</Card>
|
| 682 |
-
|
| 683 |
-
<Card className="bg-slate-900/50 border-slate-800">
|
| 684 |
-
<CardHeader><CardTitle>Historias Creadas</CardTitle></CardHeader>
|
| 685 |
-
<CardContent>
|
| 686 |
-
<ScrollArea className="h-64">
|
| 687 |
-
{stories.length === 0 ? (
|
| 688 |
-
<p className="text-slate-400 text-center py-8">No hay historias</p>
|
| 689 |
-
) : (
|
| 690 |
-
<div className="space-y-2">
|
| 691 |
-
{stories.map((s) => (
|
| 692 |
-
<div key={s.id} className="p-3 rounded-lg bg-slate-800/50">
|
| 693 |
-
<div className="flex justify-between items-start">
|
| 694 |
-
<div>
|
| 695 |
-
<p className="font-medium">{s.title}</p>
|
| 696 |
-
<p className="text-xs text-slate-400">{s.totalEpisodes} episodios • {s.genre}</p>
|
| 697 |
-
</div>
|
| 698 |
-
<Badge className={getStatusColor(s.status)}>{s.status}</Badge>
|
| 699 |
-
</div>
|
| 700 |
-
</div>
|
| 701 |
-
))}
|
| 702 |
-
</div>
|
| 703 |
-
)}
|
| 704 |
-
</ScrollArea>
|
| 705 |
-
</CardContent>
|
| 706 |
-
</Card>
|
| 707 |
-
</div>
|
| 708 |
-
)}
|
| 709 |
-
|
| 710 |
-
{/* Automation */}
|
| 711 |
-
{activeTab === "automation" && (
|
| 712 |
-
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
| 713 |
-
<Card className="bg-slate-900/50 border-slate-800">
|
| 714 |
-
<CardHeader><CardTitle className="flex items-center gap-2"><Zap className="h-5 w-5 text-amber-400" />Crear Automatización</CardTitle></CardHeader>
|
| 715 |
-
<CardContent className="space-y-4">
|
| 716 |
-
<Input placeholder="Nombre de la automatización" value={automationName} onChange={(e) => setAutomationName(e.target.value)} className="bg-slate-800 border-slate-700" />
|
| 717 |
-
<div>
|
| 718 |
-
<Label className="text-xs text-slate-400">Tipo</Label>
|
| 719 |
-
<Select value={automationType} onValueChange={setAutomationType}>
|
| 720 |
-
<SelectTrigger className="bg-slate-800 border-slate-700 mt-1"><SelectValue /></SelectTrigger>
|
| 721 |
-
<SelectContent>
|
| 722 |
-
<SelectItem value="content_generation">Generación de Contenido</SelectItem>
|
| 723 |
-
<SelectItem value="posting">Publicación Automática</SelectItem>
|
| 724 |
-
<SelectItem value="cross_posting">Cross-Posting</SelectItem>
|
| 725 |
-
<SelectItem value="trend_tracking">Seguimiento de Tendencias</SelectItem>
|
| 726 |
-
</SelectContent>
|
| 727 |
-
</Select>
|
| 728 |
-
</div>
|
| 729 |
-
<Button onClick={handleCreateAutomation} className="w-full bg-amber-600 hover:bg-amber-700">
|
| 730 |
-
<Zap className="h-4 w-4 mr-2" />Crear
|
| 731 |
-
</Button>
|
| 732 |
-
</CardContent>
|
| 733 |
-
</Card>
|
| 734 |
-
|
| 735 |
-
<Card className="bg-slate-900/50 border-slate-800">
|
| 736 |
-
<CardHeader><CardTitle>Automatizaciones Activas</CardTitle></CardHeader>
|
| 737 |
-
<CardContent>
|
| 738 |
-
<ScrollArea className="h-64">
|
| 739 |
-
{automations.length === 0 ? (
|
| 740 |
-
<p className="text-slate-400 text-center py-8">No hay automatizaciones</p>
|
| 741 |
-
) : (
|
| 742 |
-
<div className="space-y-2">
|
| 743 |
-
{automations.map((a) => (
|
| 744 |
-
<div key={a.id} className="p-3 rounded-lg bg-slate-800/50 flex items-center justify-between">
|
| 745 |
-
<div>
|
| 746 |
-
<p className="font-medium text-sm">{a.name}</p>
|
| 747 |
-
<p className="text-xs text-slate-400">{a.type} • {a.runCount} ejecuciones</p>
|
| 748 |
-
</div>
|
| 749 |
-
<Button size="sm" variant="ghost" onClick={() => handleToggleAutomation(a.id, a.isActive)}>
|
| 750 |
-
{a.isActive ? <Pause className="h-4 w-4" /> : <Play className="h-4 w-4" />}
|
| 751 |
-
</Button>
|
| 752 |
-
</div>
|
| 753 |
-
))}
|
| 754 |
-
</div>
|
| 755 |
-
)}
|
| 756 |
-
</ScrollArea>
|
| 757 |
-
</CardContent>
|
| 758 |
-
</Card>
|
| 759 |
-
</div>
|
| 760 |
-
)}
|
| 761 |
-
|
| 762 |
-
{/* Influencers IA */}
|
| 763 |
-
{activeTab === "influencers" && (
|
| 764 |
-
<div className="space-y-6">
|
| 765 |
-
<Card className="bg-slate-900/50 border-slate-800">
|
| 766 |
-
<CardHeader>
|
| 767 |
-
<CardTitle className="flex items-center gap-2"><Users2 className="h-5 w-5 text-pink-400" />Análisis de Influencers IA Famosos</CardTitle>
|
| 768 |
-
<CardDescription>Estudia los patrones de influencers IA exitosos para aplicarlos a tu contenido</CardDescription>
|
| 769 |
-
</CardHeader>
|
| 770 |
-
<CardContent className="space-y-4">
|
| 771 |
-
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
| 772 |
-
<div>
|
| 773 |
-
<Label className="text-xs text-slate-400">Tu Nicho</Label>
|
| 774 |
-
<Input placeholder="lifestyle, fashion..." value={influencerNiche} onChange={(e) => setInfluencerNiche(e.target.value)} className="bg-slate-800 border-slate-700 mt-1" />
|
| 775 |
-
</div>
|
| 776 |
-
<div>
|
| 777 |
-
<Label className="text-xs text-slate-400">Plataforma Objetivo</Label>
|
| 778 |
-
<Select value={influencerPlatform} onValueChange={setInfluencerPlatform}>
|
| 779 |
-
<SelectTrigger className="bg-slate-800 border-slate-700 mt-1"><SelectValue placeholder="Todas" /></SelectTrigger>
|
| 780 |
-
<SelectContent>
|
| 781 |
-
<SelectItem value="">Todas</SelectItem>
|
| 782 |
-
<SelectItem value="instagram">Instagram</SelectItem>
|
| 783 |
-
<SelectItem value="tiktok">TikTok</SelectItem>
|
| 784 |
-
<SelectItem value="youtube">YouTube</SelectItem>
|
| 785 |
-
</SelectContent>
|
| 786 |
-
</Select>
|
| 787 |
-
</div>
|
| 788 |
-
<div className="flex items-end">
|
| 789 |
-
<div className="flex items-center gap-2 pb-2">
|
| 790 |
-
<Switch checked={influencerWithPets} onCheckedChange={setInfluencerWithPets} />
|
| 791 |
-
<Label className="text-sm text-slate-400">Con mascotas</Label>
|
| 792 |
-
</div>
|
| 793 |
-
</div>
|
| 794 |
-
<div className="flex items-end">
|
| 795 |
-
<Button onClick={handleAnalyzeInfluencers} disabled={influencerLoading} className="w-full bg-pink-600 hover:bg-pink-700">
|
| 796 |
-
{influencerLoading ? <RefreshCw className="h-4 w-4 mr-2 animate-spin" /> : <Lightbulb className="h-4 w-4 mr-2" />}Analizar
|
| 797 |
-
</Button>
|
| 798 |
-
</div>
|
| 799 |
-
</div>
|
| 800 |
-
</CardContent>
|
| 801 |
-
</Card>
|
| 802 |
-
|
| 803 |
-
{influencers.length > 0 && (
|
| 804 |
-
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
| 805 |
-
{influencers.map((inf, i) => (
|
| 806 |
-
<Card key={i} className="bg-slate-900/50 border-slate-800">
|
| 807 |
-
<CardContent className="p-4">
|
| 808 |
-
<div className="flex items-center gap-2 mb-2">
|
| 809 |
-
<div className="w-8 h-8 rounded-full bg-gradient-to-br from-pink-500 to-purple-500 flex items-center justify-center">
|
| 810 |
-
<Star className="h-4 w-4 text-white" />
|
| 811 |
-
</div>
|
| 812 |
-
<div>
|
| 813 |
-
<p className="font-medium text-sm">{inf.name}</p>
|
| 814 |
-
<p className="text-xs text-slate-400">{inf.handle}</p>
|
| 815 |
-
</div>
|
| 816 |
-
</div>
|
| 817 |
-
<div className="space-y-1 text-xs">
|
| 818 |
-
<p><span className="text-slate-400">Seguidores:</span> <span className="text-green-400">{(inf.followers / 1000000).toFixed(1)}M</span></p>
|
| 819 |
-
<p><span className="text-slate-400">Engagement:</span> <span className="text-blue-400">{inf.engagement}%</span></p>
|
| 820 |
-
<p><span className="text-slate-400">Nicho:</span> {inf.niche}</p>
|
| 821 |
-
{inf.petCompanion && <Badge className="bg-amber-500/20 text-amber-400 mt-1"><PawPrint className="h-3 w-3 mr-1" />{inf.petType}</Badge>}
|
| 822 |
-
</div>
|
| 823 |
-
<div className="mt-2 pt-2 border-t border-slate-700">
|
| 824 |
-
<p className="text-xs text-slate-400">Lecciones:</p>
|
| 825 |
-
<ul className="text-xs mt-1 space-y-1">
|
| 826 |
-
{inf.keyLessons.slice(0, 2).map((l, j) => (
|
| 827 |
-
<li key={j} className="text-slate-300">• {l}</li>
|
| 828 |
-
))}
|
| 829 |
-
</ul>
|
| 830 |
-
</div>
|
| 831 |
-
</CardContent>
|
| 832 |
-
</Card>
|
| 833 |
-
))}
|
| 834 |
-
</div>
|
| 835 |
-
)}
|
| 836 |
-
|
| 837 |
-
{characterConcept && (
|
| 838 |
-
<Card className="bg-slate-900/50 border-slate-800 border-violet-500/30">
|
| 839 |
-
<CardHeader>
|
| 840 |
-
<CardTitle className="flex items-center gap-2 text-violet-400"><Sparkles className="h-5 w-5" />Concepto de Personaje Sugerido</CardTitle>
|
| 841 |
-
</CardHeader>
|
| 842 |
-
<CardContent>
|
| 843 |
-
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
| 844 |
-
<div>
|
| 845 |
-
<h4 className="font-medium mb-2">Personaje</h4>
|
| 846 |
-
<div className="p-3 rounded-lg bg-slate-800/50 text-sm">
|
| 847 |
-
{characterConcept.character && (
|
| 848 |
-
<>
|
| 849 |
-
<p><strong>Nombre:</strong> {String(characterConcept.character.name || "")}</p>
|
| 850 |
-
<p className="mt-1"><strong>Personalidad:</strong> {String(characterConcept.character.personality || "")}</p>
|
| 851 |
-
<p className="mt-1"><strong>Historia:</strong> {String(characterConcept.character.backstory || "")}</p>
|
| 852 |
-
</>
|
| 853 |
-
)}
|
| 854 |
-
</div>
|
| 855 |
-
</div>
|
| 856 |
-
<div>
|
| 857 |
-
<h4 className="font-medium mb-2">Estilo Visual</h4>
|
| 858 |
-
<div className="p-3 rounded-lg bg-slate-800/50 text-sm">
|
| 859 |
-
{characterConcept.visualStyle && (
|
| 860 |
-
<>
|
| 861 |
-
<p><strong>Estética:</strong> {String(characterConcept.visualStyle.aesthetic || "")}</p>
|
| 862 |
-
<p className="mt-1"><strong>Colores:</strong> {Array.isArray(characterConcept.visualStyle.colors) ? characterConcept.visualStyle.colors.join(", ") : ""}</p>
|
| 863 |
-
</>
|
| 864 |
-
)}
|
| 865 |
-
</div>
|
| 866 |
-
{Array.isArray(characterConcept.hashtags) && characterConcept.hashtags.length > 0 && (
|
| 867 |
-
<div className="mt-3 flex flex-wrap gap-1">
|
| 868 |
-
{characterConcept.hashtags.slice(0, 10).map((tag: string, i: number) => (
|
| 869 |
-
<Badge key={i} variant="outline" className="text-xs">{tag}</Badge>
|
| 870 |
-
))}
|
| 871 |
-
</div>
|
| 872 |
-
)}
|
| 873 |
-
</div>
|
| 874 |
-
</div>
|
| 875 |
-
</CardContent>
|
| 876 |
-
</Card>
|
| 877 |
-
)}
|
| 878 |
-
</div>
|
| 879 |
-
)}
|
| 880 |
-
|
| 881 |
-
{/* Pets */}
|
| 882 |
-
{activeTab === "pets" && (
|
| 883 |
-
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
| 884 |
-
<Card className="bg-slate-900/50 border-slate-800">
|
| 885 |
-
<CardHeader>
|
| 886 |
-
<CardTitle className="flex items-center gap-2"><PawPrint className="h-5 w-5 text-amber-400" />Crear Mascota</CardTitle>
|
| 887 |
-
<CardDescription>Las mascotas aumentan el engagement hasta un 35%</CardDescription>
|
| 888 |
-
</CardHeader>
|
| 889 |
-
<CardContent className="space-y-4">
|
| 890 |
-
<div className="grid grid-cols-2 gap-4">
|
| 891 |
-
<div>
|
| 892 |
-
<Label className="text-xs text-slate-400">Nombre</Label>
|
| 893 |
-
<Input placeholder="Max, Luna..." value={petName} onChange={(e) => setPetName(e.target.value)} className="bg-slate-800 border-slate-700 mt-1" />
|
| 894 |
-
</div>
|
| 895 |
-
<div>
|
| 896 |
-
<Label className="text-xs text-slate-400">Tipo</Label>
|
| 897 |
-
<Select value={petType} onValueChange={setPetType}>
|
| 898 |
-
<SelectTrigger className="bg-slate-800 border-slate-700 mt-1"><SelectValue /></SelectTrigger>
|
| 899 |
-
<SelectContent>
|
| 900 |
-
{petTypes.map((pt) => (
|
| 901 |
-
<SelectItem key={pt.value} value={pt.value}>{pt.label}</SelectItem>
|
| 902 |
-
))}
|
| 903 |
-
</SelectContent>
|
| 904 |
-
</Select>
|
| 905 |
-
</div>
|
| 906 |
-
</div>
|
| 907 |
-
<div className="grid grid-cols-2 gap-4">
|
| 908 |
-
<div>
|
| 909 |
-
<Label className="text-xs text-slate-400">Raza (opcional)</Label>
|
| 910 |
-
<Input placeholder="Golden Retriever..." value={petBreed} onChange={(e) => setPetBreed(e.target.value)} className="bg-slate-800 border-slate-700 mt-1" />
|
| 911 |
-
</div>
|
| 912 |
-
<div>
|
| 913 |
-
<Label className="text-xs text-slate-400">Personalidad</Label>
|
| 914 |
-
<Select value={petPersonality} onValueChange={setPetPersonality}>
|
| 915 |
-
<SelectTrigger className="bg-slate-800 border-slate-700 mt-1"><SelectValue placeholder="Seleccionar" /></SelectTrigger>
|
| 916 |
-
<SelectContent>
|
| 917 |
-
<SelectItem value="playful">Juguetón</SelectItem>
|
| 918 |
-
<SelectItem value="calm">Tranquilo</SelectItem>
|
| 919 |
-
<SelectItem value="energetic">Energético</SelectItem>
|
| 920 |
-
<SelectItem value="curious">Curioso</SelectItem>
|
| 921 |
-
<SelectItem value="lazy">Perezoso</SelectItem>
|
| 922 |
-
</SelectContent>
|
| 923 |
-
</Select>
|
| 924 |
-
</div>
|
| 925 |
-
</div>
|
| 926 |
-
<Button onClick={handleCreatePet} disabled={petLoading} className="w-full bg-amber-600 hover:bg-amber-700">
|
| 927 |
-
{petLoading ? <RefreshCw className="h-4 w-4 mr-2 animate-spin" /> : <PawPrint className="h-4 w-4 mr-2" />}Crear Mascota
|
| 928 |
-
</Button>
|
| 929 |
-
</CardContent>
|
| 930 |
-
</Card>
|
| 931 |
-
|
| 932 |
-
<Card className="bg-slate-900/50 border-slate-800">
|
| 933 |
-
<CardHeader><CardTitle>Mis Mascotas</CardTitle></CardHeader>
|
| 934 |
-
<CardContent>
|
| 935 |
-
<ScrollArea className="h-64">
|
| 936 |
-
{pets.length === 0 ? (
|
| 937 |
-
<div className="text-center py-8 text-slate-400">
|
| 938 |
-
<PawPrint className="h-12 w-12 mx-auto mb-3 opacity-50" />
|
| 939 |
-
<p>No tienes mascotas</p>
|
| 940 |
-
<p className="text-xs mt-1">Las mascotas aumentan el engagement</p>
|
| 941 |
-
</div>
|
| 942 |
-
) : (
|
| 943 |
-
<div className="space-y-2">
|
| 944 |
-
{pets.map((p) => (
|
| 945 |
-
<div key={p.id} className="p-3 rounded-lg bg-slate-800/50 flex items-center justify-between">
|
| 946 |
-
<div className="flex items-center gap-3">
|
| 947 |
-
<div className="w-10 h-10 rounded-lg bg-gradient-to-br from-amber-500 to-orange-500 flex items-center justify-center">
|
| 948 |
-
<PawPrint className="h-5 w-5 text-white" />
|
| 949 |
-
</div>
|
| 950 |
-
<div>
|
| 951 |
-
<p className="font-medium">{p.name}</p>
|
| 952 |
-
<p className="text-xs text-slate-400">{p.breed || p.type} • {p.personality || "Sin personalidad"}</p>
|
| 953 |
-
</div>
|
| 954 |
-
</div>
|
| 955 |
-
<Badge className="bg-green-500/20 text-green-400">+35%</Badge>
|
| 956 |
-
</div>
|
| 957 |
-
))}
|
| 958 |
-
</div>
|
| 959 |
-
)}
|
| 960 |
-
</ScrollArea>
|
| 961 |
-
</CardContent>
|
| 962 |
-
</Card>
|
| 963 |
-
</div>
|
| 964 |
-
)}
|
| 965 |
-
|
| 966 |
-
{/* Trends */}
|
| 967 |
-
{activeTab === "trends" && (
|
| 968 |
-
<div className="space-y-6">
|
| 969 |
-
<div className="flex items-center gap-4">
|
| 970 |
-
<div className="flex items-center gap-2">
|
| 971 |
-
<Switch checked={includePetInContent} onCheckedChange={setIncludePetInContent} />
|
| 972 |
-
<Label className="text-sm text-slate-400">Incluir tendencias de mascotas</Label>
|
| 973 |
-
</div>
|
| 974 |
-
<Button onClick={handleAnalyzeTrends} disabled={trendLoading} className="bg-violet-600 hover:bg-violet-700">
|
| 975 |
-
{trendLoading ? <RefreshCw className="h-4 w-4 mr-2 animate-spin" /> : <Rocket className="h-4 w-4 mr-2" />}Analizar para Viralizar
|
| 976 |
-
</Button>
|
| 977 |
-
</div>
|
| 978 |
-
|
| 979 |
-
{/* Tendencias Actuales */}
|
| 980 |
-
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
| 981 |
-
{trends.slice(0, 8).map((t, i) => (
|
| 982 |
-
<Card key={i} className="bg-slate-900/50 border-slate-800">
|
| 983 |
-
<CardContent className="p-4">
|
| 984 |
-
<div className="flex items-center justify-between mb-2">
|
| 985 |
-
<Badge variant="outline" className="text-xs">{String(t.platform || "")}</Badge>
|
| 986 |
-
<Flame className={`h-4 w-4 ${Number(t.growth) > 30 ? "text-red-400" : "text-orange-400"}`} />
|
| 987 |
-
</div>
|
| 988 |
-
<p className="font-semibold">{String(t.name)}</p>
|
| 989 |
-
<p className="text-green-400 text-sm">+{Number(t.growth).toFixed(1)}%</p>
|
| 990 |
-
<p className="text-xs text-slate-400 mt-1">{String(t.category || "")}</p>
|
| 991 |
-
</CardContent>
|
| 992 |
-
</Card>
|
| 993 |
-
))}
|
| 994 |
-
</div>
|
| 995 |
-
|
| 996 |
-
{/* Estrategias Virales */}
|
| 997 |
-
{viralStrategies.length > 0 && (
|
| 998 |
-
<Card className="bg-slate-900/50 border-slate-800">
|
| 999 |
-
<CardHeader><CardTitle className="flex items-center gap-2"><Rocket className="h-5 w-5 text-violet-400" />Estrategias Virales</CardTitle></CardHeader>
|
| 1000 |
-
<CardContent>
|
| 1001 |
-
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
| 1002 |
-
{viralStrategies.slice(0, 4).map((s, i) => (
|
| 1003 |
-
<div key={i} className="p-3 rounded-lg bg-slate-800/50 border border-slate-700">
|
| 1004 |
-
<div className="flex items-center justify-between mb-2">
|
| 1005 |
-
<p className="font-medium text-sm">{String(s.name)}</p>
|
| 1006 |
-
<Badge className="bg-green-500/20 text-green-400 text-xs">{String(s.successRate)}%</Badge>
|
| 1007 |
-
</div>
|
| 1008 |
-
<p className="text-xs text-slate-400 line-clamp-2">{String(s.description)}</p>
|
| 1009 |
-
<div className="flex flex-wrap gap-1 mt-2">
|
| 1010 |
-
{Array.isArray(s.platforms) && s.platforms.slice(0, 3).map((p: string, j: number) => (
|
| 1011 |
-
<Badge key={j} variant="outline" className="text-xs">{p}</Badge>
|
| 1012 |
-
))}
|
| 1013 |
-
</div>
|
| 1014 |
-
</div>
|
| 1015 |
-
))}
|
| 1016 |
-
</div>
|
| 1017 |
-
</CardContent>
|
| 1018 |
-
</Card>
|
| 1019 |
-
)}
|
| 1020 |
-
|
| 1021 |
-
{/* Ideas de Contenido */}
|
| 1022 |
-
{contentIdeas.length > 0 && (
|
| 1023 |
-
<Card className="bg-slate-900/50 border-slate-800">
|
| 1024 |
-
<CardHeader><CardTitle className="flex items-center gap-2"><Lightbulb className="h-5 w-5 text-amber-400" />Ideas de Contenido Viral</CardTitle></CardHeader>
|
| 1025 |
-
<CardContent>
|
| 1026 |
-
<div className="space-y-3">
|
| 1027 |
-
{contentIdeas.slice(0, 5).map((idea, i) => (
|
| 1028 |
-
<div key={i} className="p-3 rounded-lg bg-slate-800/50 border border-slate-700">
|
| 1029 |
-
<div className="flex items-center justify-between">
|
| 1030 |
-
<div>
|
| 1031 |
-
<p className="font-medium">{String(idea.title)}</p>
|
| 1032 |
-
<p className="text-xs text-slate-400 mt-1">{String(idea.description)}</p>
|
| 1033 |
-
{idea.hook && <p className="text-xs text-violet-400 mt-1">Hook: "{String(idea.hook)}"</p>}
|
| 1034 |
-
</div>
|
| 1035 |
-
<div className="text-right">
|
| 1036 |
-
<Badge className="bg-violet-500/20 text-violet-400">{String(idea.format)}</Badge>
|
| 1037 |
-
{idea.viralScore && <p className="text-xs text-green-400 mt-1">Score: {String(idea.viralScore)}</p>}
|
| 1038 |
-
</div>
|
| 1039 |
-
</div>
|
| 1040 |
-
</div>
|
| 1041 |
-
))}
|
| 1042 |
-
</div>
|
| 1043 |
-
</CardContent>
|
| 1044 |
-
</Card>
|
| 1045 |
-
)}
|
| 1046 |
-
|
| 1047 |
-
{/* Análisis de Tendencias */}
|
| 1048 |
-
{trendAnalysis && (
|
| 1049 |
-
<Card className="bg-slate-900/50 border-slate-800 border-violet-500/30">
|
| 1050 |
-
<CardHeader>
|
| 1051 |
-
<CardTitle className="flex items-center gap-2 text-violet-400"><Target className="h-5 w-5" />Plan para Viralizar</CardTitle>
|
| 1052 |
-
</CardHeader>
|
| 1053 |
-
<CardContent>
|
| 1054 |
-
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
| 1055 |
-
<div>
|
| 1056 |
-
<h4 className="font-medium mb-2">Tendencias Emergentes</h4>
|
| 1057 |
-
<div className="space-y-2">
|
| 1058 |
-
{Array.isArray(trendAnalysis.emergingTrends) && trendAnalysis.emergingTrends.slice(0, 3).map((t: Record<string, unknown>, i: number) => (
|
| 1059 |
-
<div key={i} className="p-2 rounded bg-slate-800/50 text-sm">
|
| 1060 |
-
<p className="font-medium">{String(t.name)}</p>
|
| 1061 |
-
<p className="text-xs text-slate-400">Potencial: {String(t.potential)}</p>
|
| 1062 |
-
</div>
|
| 1063 |
-
))}
|
| 1064 |
-
</div>
|
| 1065 |
-
</div>
|
| 1066 |
-
<div>
|
| 1067 |
-
<h4 className="font-medium mb-2">Recomendaciones</h4>
|
| 1068 |
-
<div className="space-y-1">
|
| 1069 |
-
{Array.isArray(trendAnalysis.recommendations) && trendAnalysis.recommendations.slice(0, 4).map((r: string, i: number) => (
|
| 1070 |
-
<p key={i} className="text-sm text-slate-300">• {r}</p>
|
| 1071 |
-
))}
|
| 1072 |
-
</div>
|
| 1073 |
-
{trendAnalysis.predictedViralPotential && (
|
| 1074 |
-
<div className="mt-4 p-3 rounded-lg bg-violet-500/10 border border-violet-500/30">
|
| 1075 |
-
<p className="text-sm">Potencial Viral Estimado:</p>
|
| 1076 |
-
<p className="text-2xl font-bold text-violet-400">{String(trendAnalysis.predictedViralPotential)}%</p>
|
| 1077 |
-
</div>
|
| 1078 |
-
)}
|
| 1079 |
-
</div>
|
| 1080 |
-
</div>
|
| 1081 |
-
</CardContent>
|
| 1082 |
-
</Card>
|
| 1083 |
-
)}
|
| 1084 |
-
</div>
|
| 1085 |
-
)}
|
| 1086 |
-
|
| 1087 |
-
{/* Content */}
|
| 1088 |
-
{activeTab === "content" && (
|
| 1089 |
-
<Card className="bg-slate-900/50 border-slate-800">
|
| 1090 |
-
<CardHeader><CardTitle>Contenido Generado</CardTitle></CardHeader>
|
| 1091 |
-
<CardContent>
|
| 1092 |
-
<ScrollArea className="h-96">
|
| 1093 |
-
{contents.length === 0 ? (
|
| 1094 |
-
<p className="text-slate-400 text-center py-8">No hay contenido generado</p>
|
| 1095 |
-
) : (
|
| 1096 |
-
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
| 1097 |
-
{contents.map((c) => (
|
| 1098 |
-
<div key={c.id} className="p-4 rounded-lg bg-slate-800/50 border border-slate-700">
|
| 1099 |
-
<div className="flex justify-between mb-2">
|
| 1100 |
-
<Badge className={getStatusColor(c.status)}>{c.status}</Badge>
|
| 1101 |
-
<Badge variant="outline">{c.type}</Badge>
|
| 1102 |
-
</div>
|
| 1103 |
-
<p className="font-medium line-clamp-2">{c.title}</p>
|
| 1104 |
-
<p className="text-xs text-slate-400 mt-1">{c.platform}</p>
|
| 1105 |
-
</div>
|
| 1106 |
-
))}
|
| 1107 |
-
</div>
|
| 1108 |
-
)}
|
| 1109 |
-
</ScrollArea>
|
| 1110 |
-
</CardContent>
|
| 1111 |
-
</Card>
|
| 1112 |
-
)}
|
| 1113 |
-
|
| 1114 |
-
{/* Videos */}
|
| 1115 |
-
{activeTab === "videos" && (
|
| 1116 |
-
<Card className="bg-slate-900/50 border-slate-800">
|
| 1117 |
-
<CardHeader><CardTitle className="flex items-center gap-2"><Video className="h-5 w-5 text-blue-400" />Generar Videos</CardTitle></CardHeader>
|
| 1118 |
-
<CardContent className="space-y-4">
|
| 1119 |
-
<Textarea placeholder="Describe el video..." value={userPrompt} onChange={(e) => setUserPrompt(e.target.value)} className="bg-slate-800 border-slate-700" />
|
| 1120 |
-
<Button className="w-full bg-blue-600 hover:bg-blue-700">
|
| 1121 |
-
<Video className="h-4 w-4 mr-2" />Generar Video
|
| 1122 |
-
</Button>
|
| 1123 |
-
</CardContent>
|
| 1124 |
-
</Card>
|
| 1125 |
-
)}
|
| 1126 |
</main>
|
| 1127 |
</div>
|
| 1128 |
</div>
|
|
|
|
| 1 |
"use client";
|
| 2 |
|
| 3 |
import { useState, useEffect, useCallback } from "react";
|
| 4 |
+
import { Menu, X, RefreshCw } from "lucide-react";
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5 |
import { Button } from "@/components/ui/button";
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6 |
import { toast } from "sonner";
|
| 7 |
|
| 8 |
+
import {
|
| 9 |
+
DashboardSidebar,
|
| 10 |
+
PromptEngineerTab,
|
| 11 |
+
ImagesTab,
|
| 12 |
+
VideosTab,
|
| 13 |
+
MonetizationTab,
|
| 14 |
+
PostsTab,
|
| 15 |
+
StorytellingTab,
|
| 16 |
+
AutomationTab,
|
| 17 |
+
InfluencersTab,
|
| 18 |
+
PetsTab,
|
| 19 |
+
TrendsTab,
|
| 20 |
+
ContentTab,
|
| 21 |
+
} from "@/components/dashboard";
|
| 22 |
+
|
| 23 |
+
import { apiFetch, type Content, type Platform, type Post, type Story, type Automation, type Pet } from "@/components/dashboard/types";
|
|
|
|
|
|
|
| 24 |
|
| 25 |
export default function Dashboard() {
|
| 26 |
const [sidebarOpen, setSidebarOpen] = useState(true);
|
| 27 |
const [activeTab, setActiveTab] = useState("prompt-engineer");
|
| 28 |
const [loading, setLoading] = useState(false);
|
| 29 |
|
| 30 |
+
// Shared state loaded at dashboard level
|
| 31 |
+
const [contents, setContents] = useState<Content[]>([]);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 32 |
const [platforms, setPlatforms] = useState<Platform[]>([]);
|
|
|
|
|
|
|
|
|
|
| 33 |
const [posts, setPosts] = useState<Post[]>([]);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 34 |
const [stories, setStories] = useState<Story[]>([]);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 35 |
const [automations, setAutomations] = useState<Automation[]>([]);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 36 |
const [pets, setPets] = useState<Pet[]>([]);
|
|
|
|
|
|
|
|
|
|
|
|
|
| 37 |
const [includePetInContent, setIncludePetInContent] = useState(false);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 38 |
const [stats, setStats] = useState({ images: 0, videos: 0, stories: 0, automations: 0, pets: 0 });
|
| 39 |
|
|
|
|
| 40 |
const loadData = useCallback(async () => {
|
| 41 |
setLoading(true);
|
| 42 |
try {
|
|
|
|
| 73 |
|
| 74 |
useEffect(() => { loadData(); }, [loadData]);
|
| 75 |
|
| 76 |
+
const renderActiveTab = () => {
|
| 77 |
+
switch (activeTab) {
|
| 78 |
+
case "prompt-engineer":
|
| 79 |
+
return <PromptEngineerTab pets={pets} includePetInContent={includePetInContent} setIncludePetInContent={setIncludePetInContent} onImageGenerated={loadData} />;
|
| 80 |
+
case "images":
|
| 81 |
+
return <ImagesTab pets={pets} includePetInContent={includePetInContent} setIncludePetInContent={setIncludePetInContent} onGenerated={loadData} />;
|
| 82 |
+
case "videos":
|
| 83 |
+
return <VideosTab onGenerated={loadData} />;
|
| 84 |
+
case "monetization":
|
| 85 |
+
return <MonetizationTab />;
|
| 86 |
+
case "posts":
|
| 87 |
+
return <PostsTab posts={posts} onCreated={loadData} />;
|
| 88 |
+
case "storytelling":
|
| 89 |
+
return <StorytellingTab stories={stories} onCreated={loadData} />;
|
| 90 |
+
case "automation":
|
| 91 |
+
return <AutomationTab automations={automations} onCreated={loadData} />;
|
| 92 |
+
case "influencers":
|
| 93 |
+
return <InfluencersTab />;
|
| 94 |
+
case "pets":
|
| 95 |
+
return <PetsTab pets={pets} onCreated={loadData} />;
|
| 96 |
+
case "trends":
|
| 97 |
+
return <TrendsTab />;
|
| 98 |
+
case "content":
|
| 99 |
+
return <ContentTab contents={contents} />;
|
| 100 |
+
default:
|
| 101 |
+
return null;
|
| 102 |
+
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 103 |
};
|
| 104 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 105 |
return (
|
| 106 |
+
<div className="min-h-screen bg-gradient-to-b from-slate-950 via-slate-900 to-slate-950 text-white">
|
| 107 |
+
<DashboardSidebar sidebarOpen={sidebarOpen} activeTab={activeTab} setActiveTab={setActiveTab} stats={stats} />
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 108 |
|
|
|
|
| 109 |
<div className={`transition-all duration-300 ${sidebarOpen ? "ml-64" : "ml-0"}`}>
|
| 110 |
+
{/* Header */}
|
| 111 |
+
<header className="sticky top-0 bg-slate-900/80 backdrop-blur-xl border-b border-slate-800 z-40">
|
| 112 |
<div className="flex items-center justify-between px-6 py-3">
|
| 113 |
<div className="flex items-center gap-3">
|
| 114 |
+
<Button variant="ghost" size="icon" onClick={() => setSidebarOpen(!sidebarOpen)}>
|
| 115 |
{sidebarOpen ? <X className="h-5 w-5" /> : <Menu className="h-5 w-5" />}
|
| 116 |
</Button>
|
| 117 |
+
<h2 className="text-lg font-semibold capitalize">{activeTab.replace(/-/g, " ")}</h2>
|
| 118 |
</div>
|
| 119 |
+
<Button variant="ghost" size="sm" onClick={loadData} disabled={loading}>
|
| 120 |
<RefreshCw className={`h-4 w-4 mr-2 ${loading ? "animate-spin" : ""}`} />Actualizar
|
| 121 |
</Button>
|
| 122 |
</div>
|
| 123 |
</header>
|
| 124 |
|
| 125 |
+
{/* Main Content */}
|
| 126 |
<main className="p-6">
|
| 127 |
+
{renderActiveTab()}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 128 |
</main>
|
| 129 |
</div>
|
| 130 |
</div>
|
src/components/dashboard/AutomationTab.tsx
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
|
| 3 |
+
import { useState } from "react";
|
| 4 |
+
import { Zap, Play, Pause } from "lucide-react";
|
| 5 |
+
import { Button } from "@/components/ui/button";
|
| 6 |
+
import { Input } from "@/components/ui/input";
|
| 7 |
+
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
| 8 |
+
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
| 9 |
+
import { ScrollArea } from "@/components/ui/scroll-area";
|
| 10 |
+
import { Label } from "@/components/ui/label";
|
| 11 |
+
import { toast } from "sonner";
|
| 12 |
+
import { apiFetch, type Automation } from "./types";
|
| 13 |
+
|
| 14 |
+
interface Props { automations: Automation[]; onCreated: () => void; }
|
| 15 |
+
|
| 16 |
+
export default function AutomationTab({ automations, onCreated }: Props) {
|
| 17 |
+
const [automationName, setAutomationName] = useState("");
|
| 18 |
+
const [automationType, setAutomationType] = useState("content_generation");
|
| 19 |
+
|
| 20 |
+
const handleCreateAutomation = async () => {
|
| 21 |
+
if (!automationName.trim()) { toast.error("Dale un nombre"); return; }
|
| 22 |
+
try {
|
| 23 |
+
const result = await apiFetch("/automation", {
|
| 24 |
+
method: "POST",
|
| 25 |
+
body: JSON.stringify({
|
| 26 |
+
name: automationName, type: automationType, trigger: "schedule",
|
| 27 |
+
triggerConfig: { schedule: "daily:09:00" },
|
| 28 |
+
actions: [{ type: "generate_content", prompt: "Genera contenido de lifestyle" }],
|
| 29 |
+
}),
|
| 30 |
+
});
|
| 31 |
+
if (result.success) {
|
| 32 |
+
toast.success("Automatización creada");
|
| 33 |
+
setAutomationName("");
|
| 34 |
+
onCreated();
|
| 35 |
+
} else toast.error(result.error);
|
| 36 |
+
} catch { toast.error("Error"); }
|
| 37 |
+
};
|
| 38 |
+
|
| 39 |
+
const handleToggleAutomation = async (id: string, currentStatus: boolean) => {
|
| 40 |
+
try {
|
| 41 |
+
await apiFetch("/automation", {
|
| 42 |
+
method: "PUT",
|
| 43 |
+
body: JSON.stringify({ id, isActive: !currentStatus }),
|
| 44 |
+
});
|
| 45 |
+
onCreated();
|
| 46 |
+
} catch { toast.error("Error"); }
|
| 47 |
+
};
|
| 48 |
+
|
| 49 |
+
return (
|
| 50 |
+
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
| 51 |
+
<Card className="bg-slate-900/50 border-slate-800">
|
| 52 |
+
<CardHeader><CardTitle className="flex items-center gap-2"><Zap className="h-5 w-5 text-amber-400" />Crear Automatización</CardTitle></CardHeader>
|
| 53 |
+
<CardContent className="space-y-4">
|
| 54 |
+
<Input placeholder="Nombre de la automatización" value={automationName} onChange={(e) => setAutomationName(e.target.value)} className="bg-slate-800 border-slate-700" />
|
| 55 |
+
<div>
|
| 56 |
+
<Label className="text-xs text-slate-400">Tipo</Label>
|
| 57 |
+
<Select value={automationType} onValueChange={setAutomationType}>
|
| 58 |
+
<SelectTrigger className="bg-slate-800 border-slate-700 mt-1"><SelectValue /></SelectTrigger>
|
| 59 |
+
<SelectContent>
|
| 60 |
+
<SelectItem value="content_generation">Generación de Contenido</SelectItem>
|
| 61 |
+
<SelectItem value="posting">Publicación Automática</SelectItem>
|
| 62 |
+
<SelectItem value="cross_posting">Cross-Posting</SelectItem>
|
| 63 |
+
<SelectItem value="trend_tracking">Seguimiento de Tendencias</SelectItem>
|
| 64 |
+
</SelectContent>
|
| 65 |
+
</Select>
|
| 66 |
+
</div>
|
| 67 |
+
<Button onClick={handleCreateAutomation} className="w-full bg-amber-600 hover:bg-amber-700">
|
| 68 |
+
<Zap className="h-4 w-4 mr-2" />Crear
|
| 69 |
+
</Button>
|
| 70 |
+
</CardContent>
|
| 71 |
+
</Card>
|
| 72 |
+
|
| 73 |
+
<Card className="bg-slate-900/50 border-slate-800">
|
| 74 |
+
<CardHeader><CardTitle>Automatizaciones Activas</CardTitle></CardHeader>
|
| 75 |
+
<CardContent>
|
| 76 |
+
<ScrollArea className="h-64">
|
| 77 |
+
{automations.length === 0 ? (
|
| 78 |
+
<p className="text-slate-400 text-center py-8">No hay automatizaciones</p>
|
| 79 |
+
) : (
|
| 80 |
+
<div className="space-y-2">
|
| 81 |
+
{automations.map((a) => (
|
| 82 |
+
<div key={a.id} className="p-3 rounded-lg bg-slate-800/50 flex items-center justify-between">
|
| 83 |
+
<div>
|
| 84 |
+
<p className="font-medium text-sm">{a.name}</p>
|
| 85 |
+
<p className="text-xs text-slate-400">{a.type} • {a.runCount} ejecuciones</p>
|
| 86 |
+
</div>
|
| 87 |
+
<Button size="sm" variant="ghost" onClick={() => handleToggleAutomation(a.id, a.isActive)}>
|
| 88 |
+
{a.isActive ? <Pause className="h-4 w-4" /> : <Play className="h-4 w-4" />}
|
| 89 |
+
</Button>
|
| 90 |
+
</div>
|
| 91 |
+
))}
|
| 92 |
+
</div>
|
| 93 |
+
)}
|
| 94 |
+
</ScrollArea>
|
| 95 |
+
</CardContent>
|
| 96 |
+
</Card>
|
| 97 |
+
</div>
|
| 98 |
+
);
|
| 99 |
+
}
|
src/components/dashboard/ContentTab.tsx
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
|
| 3 |
+
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
| 4 |
+
import { Badge } from "@/components/ui/badge";
|
| 5 |
+
import { ScrollArea } from "@/components/ui/scroll-area";
|
| 6 |
+
import { getStatusColor, type Content } from "./types";
|
| 7 |
+
|
| 8 |
+
interface Props { contents: Content[]; }
|
| 9 |
+
|
| 10 |
+
export default function ContentTab({ contents }: Props) {
|
| 11 |
+
return (
|
| 12 |
+
<Card className="bg-slate-900/50 border-slate-800">
|
| 13 |
+
<CardHeader><CardTitle>Contenido Generado</CardTitle></CardHeader>
|
| 14 |
+
<CardContent>
|
| 15 |
+
<ScrollArea className="h-96">
|
| 16 |
+
{contents.length === 0 ? (
|
| 17 |
+
<p className="text-slate-400 text-center py-8">No hay contenido generado</p>
|
| 18 |
+
) : (
|
| 19 |
+
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
| 20 |
+
{contents.map((c) => (
|
| 21 |
+
<div key={c.id} className="p-4 rounded-lg bg-slate-800/50 border border-slate-700">
|
| 22 |
+
<div className="flex justify-between mb-2">
|
| 23 |
+
<Badge className={getStatusColor(c.status)}>{c.status}</Badge>
|
| 24 |
+
<Badge variant="outline">{c.type}</Badge>
|
| 25 |
+
</div>
|
| 26 |
+
<p className="font-medium line-clamp-2">{c.title}</p>
|
| 27 |
+
<p className="text-xs text-slate-400 mt-1">{c.platform}</p>
|
| 28 |
+
</div>
|
| 29 |
+
))}
|
| 30 |
+
</div>
|
| 31 |
+
)}
|
| 32 |
+
</ScrollArea>
|
| 33 |
+
</CardContent>
|
| 34 |
+
</Card>
|
| 35 |
+
);
|
| 36 |
+
}
|
src/components/dashboard/DashboardSidebar.tsx
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
|
| 3 |
+
import { motion, AnimatePresence } from "framer-motion";
|
| 4 |
+
import {
|
| 5 |
+
Bot, Wand2, ImageIcon, Video, FolderGit2,
|
| 6 |
+
DollarSign, Film, Zap, TrendingUp, Calendar,
|
| 7 |
+
PawPrint, Users2
|
| 8 |
+
} from "lucide-react";
|
| 9 |
+
|
| 10 |
+
interface Props {
|
| 11 |
+
sidebarOpen: boolean;
|
| 12 |
+
activeTab: string;
|
| 13 |
+
setActiveTab: (tab: string) => void;
|
| 14 |
+
stats: { images: number; videos: number; stories: number; pets: number };
|
| 15 |
+
}
|
| 16 |
+
|
| 17 |
+
const navItems = [
|
| 18 |
+
{ id: "prompt-engineer", icon: Wand2, label: "Ingeniero IA" },
|
| 19 |
+
{ id: "images", icon: ImageIcon, label: "Imágenes" },
|
| 20 |
+
{ id: "videos", icon: Video, label: "Videos" },
|
| 21 |
+
{ id: "monetization", icon: DollarSign, label: "Monetización" },
|
| 22 |
+
{ id: "posts", icon: Calendar, label: "Publicaciones" },
|
| 23 |
+
{ id: "storytelling", icon: Film, label: "Storytelling" },
|
| 24 |
+
{ id: "automation", icon: Zap, label: "Automatización" },
|
| 25 |
+
{ id: "influencers", icon: Users2, label: "Influencers IA" },
|
| 26 |
+
{ id: "pets", icon: PawPrint, label: "Mascotas" },
|
| 27 |
+
{ id: "trends", icon: TrendingUp, label: "Tendencias" },
|
| 28 |
+
{ id: "content", icon: FolderGit2, label: "Contenido" },
|
| 29 |
+
];
|
| 30 |
+
|
| 31 |
+
export default function DashboardSidebar({ sidebarOpen, activeTab, setActiveTab, stats }: Props) {
|
| 32 |
+
return (
|
| 33 |
+
<AnimatePresence mode="wait">
|
| 34 |
+
{sidebarOpen && (
|
| 35 |
+
<motion.aside
|
| 36 |
+
initial={{ x: -280 }} animate={{ x: 0 }} exit={{ x: -280 }}
|
| 37 |
+
className="fixed left-0 top-0 h-full w-64 bg-slate-900/90 backdrop-blur-xl border-r border-slate-800 z-50"
|
| 38 |
+
>
|
| 39 |
+
<div className="p-5">
|
| 40 |
+
<div className="flex items-center gap-3 mb-6">
|
| 41 |
+
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-violet-500 to-purple-600 flex items-center justify-center">
|
| 42 |
+
<Bot className="h-5 w-5" />
|
| 43 |
+
</div>
|
| 44 |
+
<div>
|
| 45 |
+
<h1 className="text-lg font-bold bg-gradient-to-r from-violet-400 to-purple-400 bg-clip-text text-transparent">AI Influencer Studio</h1>
|
| 46 |
+
<p className="text-xs text-slate-400">Plataforma SaaS</p>
|
| 47 |
+
</div>
|
| 48 |
+
</div>
|
| 49 |
+
|
| 50 |
+
<nav className="space-y-1">
|
| 51 |
+
{navItems.map((item) => (
|
| 52 |
+
<button key={item.id} onClick={() => setActiveTab(item.id)}
|
| 53 |
+
className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-lg transition-all text-sm ${activeTab === item.id ? "bg-violet-500/20 text-violet-400 border border-violet-500/30" : "text-slate-400 hover:bg-slate-800"}`}>
|
| 54 |
+
<item.icon className="h-4 w-4" /><span>{item.label}</span>
|
| 55 |
+
</button>
|
| 56 |
+
))}
|
| 57 |
+
</nav>
|
| 58 |
+
</div>
|
| 59 |
+
|
| 60 |
+
<div className="absolute bottom-0 left-0 right-0 p-4 border-t border-slate-800">
|
| 61 |
+
<div className="grid grid-cols-2 gap-2 text-center text-xs">
|
| 62 |
+
<div><p className="text-xl font-bold text-violet-400">{stats.images}</p><p className="text-slate-500">Imágenes</p></div>
|
| 63 |
+
<div><p className="text-xl font-bold text-blue-400">{stats.videos}</p><p className="text-slate-500">Videos</p></div>
|
| 64 |
+
<div><p className="text-xl font-bold text-green-400">{stats.stories}</p><p className="text-slate-500">Historias</p></div>
|
| 65 |
+
<div><p className="text-xl font-bold text-amber-400">{stats.pets}</p><p className="text-slate-500">Mascotas</p></div>
|
| 66 |
+
</div>
|
| 67 |
+
</div>
|
| 68 |
+
</motion.aside>
|
| 69 |
+
)}
|
| 70 |
+
</AnimatePresence>
|
| 71 |
+
);
|
| 72 |
+
}
|
src/components/dashboard/ImagesTab.tsx
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
|
| 3 |
+
import { useState } from "react";
|
| 4 |
+
import { ImageIcon, RefreshCw } from "lucide-react";
|
| 5 |
+
import { Button } from "@/components/ui/button";
|
| 6 |
+
import { Textarea } from "@/components/ui/textarea";
|
| 7 |
+
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
| 8 |
+
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
| 9 |
+
import { Label } from "@/components/ui/label";
|
| 10 |
+
import { Switch } from "@/components/ui/switch";
|
| 11 |
+
import { toast } from "sonner";
|
| 12 |
+
import { apiFetch, type Pet } from "./types";
|
| 13 |
+
|
| 14 |
+
interface Props {
|
| 15 |
+
pets: Pet[];
|
| 16 |
+
includePetInContent: boolean;
|
| 17 |
+
setIncludePetInContent: (v: boolean) => void;
|
| 18 |
+
onGenerated: () => void;
|
| 19 |
+
}
|
| 20 |
+
|
| 21 |
+
export default function ImagesTab({ pets, includePetInContent, setIncludePetInContent, onGenerated }: Props) {
|
| 22 |
+
const [userPrompt, setUserPrompt] = useState("");
|
| 23 |
+
const [imageStyle, setImageStyle] = useState("realistic");
|
| 24 |
+
const [imagePlatform, setImagePlatform] = useState("general");
|
| 25 |
+
const [imageLoading, setImageLoading] = useState(false);
|
| 26 |
+
|
| 27 |
+
const handleGenerateImage = async () => {
|
| 28 |
+
if (!userPrompt.trim()) { toast.error("Describe la imagen"); return; }
|
| 29 |
+
setImageLoading(true);
|
| 30 |
+
try {
|
| 31 |
+
const result = await apiFetch("/generate/image", {
|
| 32 |
+
method: "POST",
|
| 33 |
+
body: JSON.stringify({
|
| 34 |
+
prompt: userPrompt,
|
| 35 |
+
platform: imagePlatform,
|
| 36 |
+
style: imageStyle,
|
| 37 |
+
includePet: includePetInContent,
|
| 38 |
+
petId: pets[0]?.id
|
| 39 |
+
}),
|
| 40 |
+
});
|
| 41 |
+
if (result.success) {
|
| 42 |
+
toast.success("Imagen generada");
|
| 43 |
+
onGenerated();
|
| 44 |
+
} else toast.error(result.error);
|
| 45 |
+
} catch { toast.error("Error"); }
|
| 46 |
+
finally { setImageLoading(false); }
|
| 47 |
+
};
|
| 48 |
+
|
| 49 |
+
return (
|
| 50 |
+
<Card className="bg-slate-900/50 border-slate-800">
|
| 51 |
+
<CardHeader><CardTitle className="flex items-center gap-2"><ImageIcon className="h-5 w-5 text-green-400" />Generar Imágenes</CardTitle></CardHeader>
|
| 52 |
+
<CardContent className="space-y-4">
|
| 53 |
+
<Textarea placeholder="Describe la imagen..." value={userPrompt} onChange={(e) => setUserPrompt(e.target.value)} className="bg-slate-800 border-slate-700" />
|
| 54 |
+
<div className="grid grid-cols-3 gap-4">
|
| 55 |
+
<div>
|
| 56 |
+
<Label className="text-xs text-slate-400">Estilo</Label>
|
| 57 |
+
<Select value={imageStyle} onValueChange={setImageStyle}>
|
| 58 |
+
<SelectTrigger className="bg-slate-800 border-slate-700 mt-1"><SelectValue /></SelectTrigger>
|
| 59 |
+
<SelectContent>
|
| 60 |
+
<SelectItem value="realistic">Realista</SelectItem>
|
| 61 |
+
<SelectItem value="anime">Anime</SelectItem>
|
| 62 |
+
<SelectItem value="artistic">Artístico</SelectItem>
|
| 63 |
+
</SelectContent>
|
| 64 |
+
</Select>
|
| 65 |
+
</div>
|
| 66 |
+
<div>
|
| 67 |
+
<Label className="text-xs text-slate-400">Plataforma</Label>
|
| 68 |
+
<Select value={imagePlatform} onValueChange={setImagePlatform}>
|
| 69 |
+
<SelectTrigger className="bg-slate-800 border-slate-700 mt-1"><SelectValue /></SelectTrigger>
|
| 70 |
+
<SelectContent>
|
| 71 |
+
<SelectItem value="general">General</SelectItem>
|
| 72 |
+
<SelectItem value="onlyfans">OnlyFans</SelectItem>
|
| 73 |
+
<SelectItem value="instagram">Instagram</SelectItem>
|
| 74 |
+
</SelectContent>
|
| 75 |
+
</Select>
|
| 76 |
+
</div>
|
| 77 |
+
</div>
|
| 78 |
+
{pets.length > 0 && (
|
| 79 |
+
<div className="flex items-center gap-2">
|
| 80 |
+
<Switch checked={includePetInContent} onCheckedChange={setIncludePetInContent} />
|
| 81 |
+
<Label className="text-sm text-slate-400">Incluir mascota ({pets[0]?.name})</Label>
|
| 82 |
+
</div>
|
| 83 |
+
)}
|
| 84 |
+
<Button onClick={handleGenerateImage} disabled={imageLoading} className="w-full bg-green-600 hover:bg-green-700">
|
| 85 |
+
{imageLoading ? <RefreshCw className="h-4 w-4 mr-2 animate-spin" /> : <ImageIcon className="h-4 w-4 mr-2" />}Generar
|
| 86 |
+
</Button>
|
| 87 |
+
</CardContent>
|
| 88 |
+
</Card>
|
| 89 |
+
);
|
| 90 |
+
}
|
src/components/dashboard/InfluencersTab.tsx
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
|
| 3 |
+
import { useState } from "react";
|
| 4 |
+
import { Users2, Lightbulb, Star, PawPrint, Sparkles, RefreshCw } from "lucide-react";
|
| 5 |
+
import { Button } from "@/components/ui/button";
|
| 6 |
+
import { Input } from "@/components/ui/input";
|
| 7 |
+
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
| 8 |
+
import { Badge } from "@/components/ui/badge";
|
| 9 |
+
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
| 10 |
+
import { Label } from "@/components/ui/label";
|
| 11 |
+
import { Switch } from "@/components/ui/switch";
|
| 12 |
+
import { toast } from "sonner";
|
| 13 |
+
import { apiFetch, type Influencer } from "./types";
|
| 14 |
+
|
| 15 |
+
export default function InfluencersTab() {
|
| 16 |
+
const [influencers, setInfluencers] = useState<Influencer[]>([]);
|
| 17 |
+
const [influencerNiche, setInfluencerNiche] = useState("");
|
| 18 |
+
const [influencerPlatform, setInfluencerPlatform] = useState("");
|
| 19 |
+
const [influencerWithPets, setInfluencerWithPets] = useState(false);
|
| 20 |
+
const [influencerLoading, setInfluencerLoading] = useState(false);
|
| 21 |
+
const [characterConcept, setCharacterConcept] = useState<any | null>(null);
|
| 22 |
+
|
| 23 |
+
const handleAnalyzeInfluencers = async () => {
|
| 24 |
+
setInfluencerLoading(true);
|
| 25 |
+
try {
|
| 26 |
+
const result = await apiFetch("/influencers", {
|
| 27 |
+
method: "POST",
|
| 28 |
+
body: JSON.stringify({
|
| 29 |
+
targetNiche: influencerNiche || "lifestyle",
|
| 30 |
+
targetPlatform: influencerPlatform || undefined,
|
| 31 |
+
includePets: influencerWithPets
|
| 32 |
+
}),
|
| 33 |
+
});
|
| 34 |
+
if (result.success) {
|
| 35 |
+
setInfluencers(result.referenceInfluencers || []);
|
| 36 |
+
setCharacterConcept(result.characterConcept);
|
| 37 |
+
toast.success("Análisis completado");
|
| 38 |
+
} else toast.error(result.error);
|
| 39 |
+
} catch { toast.error("Error"); }
|
| 40 |
+
finally { setInfluencerLoading(false); }
|
| 41 |
+
};
|
| 42 |
+
|
| 43 |
+
return (
|
| 44 |
+
<div className="space-y-6">
|
| 45 |
+
<Card className="bg-slate-900/50 border-slate-800">
|
| 46 |
+
<CardHeader>
|
| 47 |
+
<CardTitle className="flex items-center gap-2"><Users2 className="h-5 w-5 text-pink-400" />Análisis de Influencers IA Famosos</CardTitle>
|
| 48 |
+
<CardDescription>Estudia los patrones de influencers IA exitosos para aplicarlos a tu contenido</CardDescription>
|
| 49 |
+
</CardHeader>
|
| 50 |
+
<CardContent className="space-y-4">
|
| 51 |
+
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
| 52 |
+
<div>
|
| 53 |
+
<Label className="text-xs text-slate-400">Tu Nicho</Label>
|
| 54 |
+
<Input placeholder="lifestyle, fashion..." value={influencerNiche} onChange={(e) => setInfluencerNiche(e.target.value)} className="bg-slate-800 border-slate-700 mt-1" />
|
| 55 |
+
</div>
|
| 56 |
+
<div>
|
| 57 |
+
<Label className="text-xs text-slate-400">Plataforma Objetivo</Label>
|
| 58 |
+
<Select value={influencerPlatform} onValueChange={setInfluencerPlatform}>
|
| 59 |
+
<SelectTrigger className="bg-slate-800 border-slate-700 mt-1"><SelectValue placeholder="Todas" /></SelectTrigger>
|
| 60 |
+
<SelectContent>
|
| 61 |
+
<SelectItem value="">Todas</SelectItem>
|
| 62 |
+
<SelectItem value="instagram">Instagram</SelectItem>
|
| 63 |
+
<SelectItem value="tiktok">TikTok</SelectItem>
|
| 64 |
+
<SelectItem value="youtube">YouTube</SelectItem>
|
| 65 |
+
</SelectContent>
|
| 66 |
+
</Select>
|
| 67 |
+
</div>
|
| 68 |
+
<div className="flex items-end">
|
| 69 |
+
<div className="flex items-center gap-2 pb-2">
|
| 70 |
+
<Switch checked={influencerWithPets} onCheckedChange={setInfluencerWithPets} />
|
| 71 |
+
<Label className="text-sm text-slate-400">Con mascotas</Label>
|
| 72 |
+
</div>
|
| 73 |
+
</div>
|
| 74 |
+
<div className="flex items-end">
|
| 75 |
+
<Button onClick={handleAnalyzeInfluencers} disabled={influencerLoading} className="w-full bg-pink-600 hover:bg-pink-700">
|
| 76 |
+
{influencerLoading ? <RefreshCw className="h-4 w-4 mr-2 animate-spin" /> : <Lightbulb className="h-4 w-4 mr-2" />}Analizar
|
| 77 |
+
</Button>
|
| 78 |
+
</div>
|
| 79 |
+
</div>
|
| 80 |
+
</CardContent>
|
| 81 |
+
</Card>
|
| 82 |
+
|
| 83 |
+
{influencers.length > 0 && (
|
| 84 |
+
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
| 85 |
+
{influencers.map((inf, i) => (
|
| 86 |
+
<Card key={i} className="bg-slate-900/50 border-slate-800">
|
| 87 |
+
<CardContent className="p-4">
|
| 88 |
+
<div className="flex items-center gap-2 mb-2">
|
| 89 |
+
<div className="w-8 h-8 rounded-full bg-gradient-to-br from-pink-500 to-purple-500 flex items-center justify-center">
|
| 90 |
+
<Star className="h-4 w-4 text-white" />
|
| 91 |
+
</div>
|
| 92 |
+
<div>
|
| 93 |
+
<p className="font-medium text-sm">{inf.name}</p>
|
| 94 |
+
<p className="text-xs text-slate-400">{inf.handle}</p>
|
| 95 |
+
</div>
|
| 96 |
+
</div>
|
| 97 |
+
<div className="space-y-1 text-xs">
|
| 98 |
+
<p><span className="text-slate-400">Seguidores:</span> <span className="text-green-400">{(inf.followers / 1000000).toFixed(1)}M</span></p>
|
| 99 |
+
<p><span className="text-slate-400">Engagement:</span> <span className="text-blue-400">{inf.engagement}%</span></p>
|
| 100 |
+
<p><span className="text-slate-400">Nicho:</span> {inf.niche}</p>
|
| 101 |
+
{inf.petCompanion && <Badge className="bg-amber-500/20 text-amber-400 mt-1"><PawPrint className="h-3 w-3 mr-1" />{inf.petType}</Badge>}
|
| 102 |
+
</div>
|
| 103 |
+
<div className="mt-2 pt-2 border-t border-slate-700">
|
| 104 |
+
<p className="text-xs text-slate-400">Lecciones:</p>
|
| 105 |
+
<ul className="text-xs mt-1 space-y-1">
|
| 106 |
+
{inf.keyLessons.slice(0, 2).map((l, j) => (
|
| 107 |
+
<li key={j} className="text-slate-300">• {l}</li>
|
| 108 |
+
))}
|
| 109 |
+
</ul>
|
| 110 |
+
</div>
|
| 111 |
+
</CardContent>
|
| 112 |
+
</Card>
|
| 113 |
+
))}
|
| 114 |
+
</div>
|
| 115 |
+
)}
|
| 116 |
+
|
| 117 |
+
{characterConcept && (
|
| 118 |
+
<Card className="bg-slate-900/50 border-slate-800 border-violet-500/30">
|
| 119 |
+
<CardHeader>
|
| 120 |
+
<CardTitle className="flex items-center gap-2 text-violet-400"><Sparkles className="h-5 w-5" />Concepto de Personaje Sugerido</CardTitle>
|
| 121 |
+
</CardHeader>
|
| 122 |
+
<CardContent>
|
| 123 |
+
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
| 124 |
+
<div>
|
| 125 |
+
<h4 className="font-medium mb-2">Personaje</h4>
|
| 126 |
+
<div className="p-3 rounded-lg bg-slate-800/50 text-sm">
|
| 127 |
+
{characterConcept.character && (
|
| 128 |
+
<>
|
| 129 |
+
<p><strong>Nombre:</strong> {String(characterConcept.character.name || "")}</p>
|
| 130 |
+
<p className="mt-1"><strong>Personalidad:</strong> {String(characterConcept.character.personality || "")}</p>
|
| 131 |
+
<p className="mt-1"><strong>Historia:</strong> {String(characterConcept.character.backstory || "")}</p>
|
| 132 |
+
</>
|
| 133 |
+
)}
|
| 134 |
+
</div>
|
| 135 |
+
</div>
|
| 136 |
+
<div>
|
| 137 |
+
<h4 className="font-medium mb-2">Estilo Visual</h4>
|
| 138 |
+
<div className="p-3 rounded-lg bg-slate-800/50 text-sm">
|
| 139 |
+
{characterConcept.visualStyle && (
|
| 140 |
+
<>
|
| 141 |
+
<p><strong>Estética:</strong> {String(characterConcept.visualStyle.aesthetic || "")}</p>
|
| 142 |
+
<p className="mt-1"><strong>Colores:</strong> {Array.isArray(characterConcept.visualStyle.colors) ? characterConcept.visualStyle.colors.join(", ") : ""}</p>
|
| 143 |
+
</>
|
| 144 |
+
)}
|
| 145 |
+
</div>
|
| 146 |
+
{Array.isArray(characterConcept.hashtags) && characterConcept.hashtags.length > 0 && (
|
| 147 |
+
<div className="mt-3 flex flex-wrap gap-1">
|
| 148 |
+
{characterConcept.hashtags.slice(0, 10).map((tag: string, i: number) => (
|
| 149 |
+
<Badge key={i} variant="outline" className="text-xs">{tag}</Badge>
|
| 150 |
+
))}
|
| 151 |
+
</div>
|
| 152 |
+
)}
|
| 153 |
+
</div>
|
| 154 |
+
</div>
|
| 155 |
+
</CardContent>
|
| 156 |
+
</Card>
|
| 157 |
+
)}
|
| 158 |
+
</div>
|
| 159 |
+
);
|
| 160 |
+
}
|
src/components/dashboard/MonetizationTab.tsx
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
|
| 3 |
+
import { DollarSign, Plus } from "lucide-react";
|
| 4 |
+
import { Button } from "@/components/ui/button";
|
| 5 |
+
import { Card, CardContent } from "@/components/ui/card";
|
| 6 |
+
import { Badge } from "@/components/ui/badge";
|
| 7 |
+
|
| 8 |
+
export default function MonetizationTab() {
|
| 9 |
+
const platforms = [
|
| 10 |
+
{ name: "OnlyFans", type: "subscription", fee: "20%", color: "from-blue-500 to-cyan-500", legal: "Adult content permitido" },
|
| 11 |
+
{ name: "Patreon", type: "subscription", fee: "12%", color: "from-orange-500 to-red-500", legal: "Sin adult content real" },
|
| 12 |
+
{ name: "Fansly", type: "mixed", fee: "20%", color: "from-purple-500 to-pink-500", legal: "Adult content permitido" },
|
| 13 |
+
{ name: "Fanvue", type: "subscription", fee: "15%", color: "from-green-500 to-teal-500", legal: "Adult content permitido" },
|
| 14 |
+
{ name: "Ko-fi", type: "tips", fee: "0%", color: "from-sky-500 to-blue-500", legal: "Sin adult content" },
|
| 15 |
+
{ name: "Instagram", type: "free", fee: "0%", color: "from-pink-500 to-purple-500", legal: "Sin desnudez" },
|
| 16 |
+
];
|
| 17 |
+
|
| 18 |
+
return (
|
| 19 |
+
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
| 20 |
+
{platforms.map((p) => (
|
| 21 |
+
<Card key={p.name} className="bg-slate-900/50 border-slate-800">
|
| 22 |
+
<CardContent className="p-4">
|
| 23 |
+
<div className={`w-10 h-10 rounded-lg bg-gradient-to-br ${p.color} flex items-center justify-center mb-3`}>
|
| 24 |
+
<DollarSign className="h-5 w-5 text-white" />
|
| 25 |
+
</div>
|
| 26 |
+
<h3 className="font-semibold">{p.name}</h3>
|
| 27 |
+
<p className="text-xs text-slate-400 mt-1">Fee: {p.fee}</p>
|
| 28 |
+
<Badge variant="outline" className="mt-2 text-xs">{p.type}</Badge>
|
| 29 |
+
<p className="text-xs text-amber-400 mt-2">⚖️ {p.legal}</p>
|
| 30 |
+
<Button size="sm" className="w-full mt-3 bg-violet-600 hover:bg-violet-700">
|
| 31 |
+
<Plus className="h-3 w-3 mr-1" />Conectar
|
| 32 |
+
</Button>
|
| 33 |
+
</CardContent>
|
| 34 |
+
</Card>
|
| 35 |
+
))}
|
| 36 |
+
</div>
|
| 37 |
+
);
|
| 38 |
+
}
|
src/components/dashboard/PetsTab.tsx
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
|
| 3 |
+
import { useState } from "react";
|
| 4 |
+
import { PawPrint, RefreshCw } from "lucide-react";
|
| 5 |
+
import { Button } from "@/components/ui/button";
|
| 6 |
+
import { Input } from "@/components/ui/input";
|
| 7 |
+
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
| 8 |
+
import { Badge } from "@/components/ui/badge";
|
| 9 |
+
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
| 10 |
+
import { ScrollArea } from "@/components/ui/scroll-area";
|
| 11 |
+
import { Label } from "@/components/ui/label";
|
| 12 |
+
import { toast } from "sonner";
|
| 13 |
+
import { apiFetch, type Pet } from "./types";
|
| 14 |
+
|
| 15 |
+
interface Props { pets: Pet[]; onCreated: () => void; }
|
| 16 |
+
|
| 17 |
+
const petTypes = [
|
| 18 |
+
{ value: "dog", label: "🐕 Perro" },
|
| 19 |
+
{ value: "cat", label: "🐱 Gato" },
|
| 20 |
+
{ value: "bird", label: "🐦 Pájaro" },
|
| 21 |
+
{ value: "rabbit", label: "🐰 Conejo" },
|
| 22 |
+
{ value: "hamster", label: "🐹 Hámster" }
|
| 23 |
+
];
|
| 24 |
+
|
| 25 |
+
export default function PetsTab({ pets, onCreated }: Props) {
|
| 26 |
+
const [petName, setPetName] = useState("");
|
| 27 |
+
const [petType, setPetType] = useState("dog");
|
| 28 |
+
const [petBreed, setPetBreed] = useState("");
|
| 29 |
+
const [petPersonality, setPetPersonality] = useState("");
|
| 30 |
+
const [petLoading, setPetLoading] = useState(false);
|
| 31 |
+
|
| 32 |
+
const handleCreatePet = async () => {
|
| 33 |
+
if (!petName.trim()) { toast.error("Dale un nombre a tu mascota"); return; }
|
| 34 |
+
setPetLoading(true);
|
| 35 |
+
try {
|
| 36 |
+
const result = await apiFetch("/pets", {
|
| 37 |
+
method: "POST",
|
| 38 |
+
body: JSON.stringify({
|
| 39 |
+
name: petName, type: petType,
|
| 40 |
+
breed: petBreed || undefined,
|
| 41 |
+
personality: petPersonality || undefined,
|
| 42 |
+
generateReference: true
|
| 43 |
+
}),
|
| 44 |
+
});
|
| 45 |
+
if (result.success) {
|
| 46 |
+
toast.success(`Mascota "${petName}" creada (+${result.engagementBoost}% engagement)`);
|
| 47 |
+
setPetName(""); setPetBreed(""); setPetPersonality("");
|
| 48 |
+
onCreated();
|
| 49 |
+
} else toast.error(result.error);
|
| 50 |
+
} catch { toast.error("Error"); }
|
| 51 |
+
finally { setPetLoading(false); }
|
| 52 |
+
};
|
| 53 |
+
|
| 54 |
+
return (
|
| 55 |
+
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
| 56 |
+
<Card className="bg-slate-900/50 border-slate-800">
|
| 57 |
+
<CardHeader>
|
| 58 |
+
<CardTitle className="flex items-center gap-2"><PawPrint className="h-5 w-5 text-amber-400" />Crear Mascota</CardTitle>
|
| 59 |
+
<CardDescription>Las mascotas aumentan el engagement hasta un 35%</CardDescription>
|
| 60 |
+
</CardHeader>
|
| 61 |
+
<CardContent className="space-y-4">
|
| 62 |
+
<div className="grid grid-cols-2 gap-4">
|
| 63 |
+
<div>
|
| 64 |
+
<Label className="text-xs text-slate-400">Nombre</Label>
|
| 65 |
+
<Input placeholder="Max, Luna..." value={petName} onChange={(e) => setPetName(e.target.value)} className="bg-slate-800 border-slate-700 mt-1" />
|
| 66 |
+
</div>
|
| 67 |
+
<div>
|
| 68 |
+
<Label className="text-xs text-slate-400">Tipo</Label>
|
| 69 |
+
<Select value={petType} onValueChange={setPetType}>
|
| 70 |
+
<SelectTrigger className="bg-slate-800 border-slate-700 mt-1"><SelectValue /></SelectTrigger>
|
| 71 |
+
<SelectContent>
|
| 72 |
+
{petTypes.map((pt) => (
|
| 73 |
+
<SelectItem key={pt.value} value={pt.value}>{pt.label}</SelectItem>
|
| 74 |
+
))}
|
| 75 |
+
</SelectContent>
|
| 76 |
+
</Select>
|
| 77 |
+
</div>
|
| 78 |
+
</div>
|
| 79 |
+
<div className="grid grid-cols-2 gap-4">
|
| 80 |
+
<div>
|
| 81 |
+
<Label className="text-xs text-slate-400">Raza (opcional)</Label>
|
| 82 |
+
<Input placeholder="Golden Retriever..." value={petBreed} onChange={(e) => setPetBreed(e.target.value)} className="bg-slate-800 border-slate-700 mt-1" />
|
| 83 |
+
</div>
|
| 84 |
+
<div>
|
| 85 |
+
<Label className="text-xs text-slate-400">Personalidad</Label>
|
| 86 |
+
<Select value={petPersonality} onValueChange={setPetPersonality}>
|
| 87 |
+
<SelectTrigger className="bg-slate-800 border-slate-700 mt-1"><SelectValue placeholder="Seleccionar" /></SelectTrigger>
|
| 88 |
+
<SelectContent>
|
| 89 |
+
<SelectItem value="playful">Juguetón</SelectItem>
|
| 90 |
+
<SelectItem value="calm">Tranquilo</SelectItem>
|
| 91 |
+
<SelectItem value="energetic">Energético</SelectItem>
|
| 92 |
+
<SelectItem value="curious">Curioso</SelectItem>
|
| 93 |
+
<SelectItem value="lazy">Perezoso</SelectItem>
|
| 94 |
+
</SelectContent>
|
| 95 |
+
</Select>
|
| 96 |
+
</div>
|
| 97 |
+
</div>
|
| 98 |
+
<Button onClick={handleCreatePet} disabled={petLoading} className="w-full bg-amber-600 hover:bg-amber-700">
|
| 99 |
+
{petLoading ? <RefreshCw className="h-4 w-4 mr-2 animate-spin" /> : <PawPrint className="h-4 w-4 mr-2" />}Crear Mascota
|
| 100 |
+
</Button>
|
| 101 |
+
</CardContent>
|
| 102 |
+
</Card>
|
| 103 |
+
|
| 104 |
+
<Card className="bg-slate-900/50 border-slate-800">
|
| 105 |
+
<CardHeader><CardTitle>Mis Mascotas</CardTitle></CardHeader>
|
| 106 |
+
<CardContent>
|
| 107 |
+
<ScrollArea className="h-64">
|
| 108 |
+
{pets.length === 0 ? (
|
| 109 |
+
<div className="text-center py-8 text-slate-400">
|
| 110 |
+
<PawPrint className="h-12 w-12 mx-auto mb-3 opacity-50" />
|
| 111 |
+
<p>No tienes mascotas</p>
|
| 112 |
+
<p className="text-xs mt-1">Las mascotas aumentan el engagement</p>
|
| 113 |
+
</div>
|
| 114 |
+
) : (
|
| 115 |
+
<div className="space-y-2">
|
| 116 |
+
{pets.map((p) => (
|
| 117 |
+
<div key={p.id} className="p-3 rounded-lg bg-slate-800/50 flex items-center justify-between">
|
| 118 |
+
<div className="flex items-center gap-3">
|
| 119 |
+
<div className="w-10 h-10 rounded-lg bg-gradient-to-br from-amber-500 to-orange-500 flex items-center justify-center">
|
| 120 |
+
<PawPrint className="h-5 w-5 text-white" />
|
| 121 |
+
</div>
|
| 122 |
+
<div>
|
| 123 |
+
<p className="font-medium">{p.name}</p>
|
| 124 |
+
<p className="text-xs text-slate-400">{p.breed || p.type} • {p.personality || "Sin personalidad"}</p>
|
| 125 |
+
</div>
|
| 126 |
+
</div>
|
| 127 |
+
<Badge className="bg-green-500/20 text-green-400">+35%</Badge>
|
| 128 |
+
</div>
|
| 129 |
+
))}
|
| 130 |
+
</div>
|
| 131 |
+
)}
|
| 132 |
+
</ScrollArea>
|
| 133 |
+
</CardContent>
|
| 134 |
+
</Card>
|
| 135 |
+
</div>
|
| 136 |
+
);
|
| 137 |
+
}
|
src/components/dashboard/PostsTab.tsx
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
|
| 3 |
+
import { useState } from "react";
|
| 4 |
+
import { Calendar } from "lucide-react";
|
| 5 |
+
import { Button } from "@/components/ui/button";
|
| 6 |
+
import { Input } from "@/components/ui/input";
|
| 7 |
+
import { Textarea } from "@/components/ui/textarea";
|
| 8 |
+
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
| 9 |
+
import { Badge } from "@/components/ui/badge";
|
| 10 |
+
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
| 11 |
+
import { ScrollArea } from "@/components/ui/scroll-area";
|
| 12 |
+
import { Label } from "@/components/ui/label";
|
| 13 |
+
import { toast } from "sonner";
|
| 14 |
+
import { apiFetch, getStatusColor, type Post } from "./types";
|
| 15 |
+
|
| 16 |
+
interface Props { posts: Post[]; onCreated: () => void; }
|
| 17 |
+
|
| 18 |
+
export default function PostsTab({ posts, onCreated }: Props) {
|
| 19 |
+
const [postTitle, setPostTitle] = useState("");
|
| 20 |
+
const [postType, setPostType] = useState("reel");
|
| 21 |
+
const [postCaption, setPostCaption] = useState("");
|
| 22 |
+
const [scheduledTime, setScheduledTime] = useState("");
|
| 23 |
+
|
| 24 |
+
const handleCreatePost = async () => {
|
| 25 |
+
if (!postTitle.trim()) { toast.error("Añade un título"); return; }
|
| 26 |
+
try {
|
| 27 |
+
const result = await apiFetch("/posts", {
|
| 28 |
+
method: "POST",
|
| 29 |
+
body: JSON.stringify({
|
| 30 |
+
title: postTitle, type: postType, caption: postCaption,
|
| 31 |
+
scheduledAt: scheduledTime || null, autoGenerateCaption: true,
|
| 32 |
+
}),
|
| 33 |
+
});
|
| 34 |
+
if (result.success) {
|
| 35 |
+
toast.success(scheduledTime ? "Post programado" : "Post creado");
|
| 36 |
+
setPostTitle(""); setPostCaption(""); setScheduledTime("");
|
| 37 |
+
onCreated();
|
| 38 |
+
} else toast.error(result.error);
|
| 39 |
+
} catch { toast.error("Error"); }
|
| 40 |
+
};
|
| 41 |
+
|
| 42 |
+
return (
|
| 43 |
+
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
| 44 |
+
<Card className="bg-slate-900/50 border-slate-800">
|
| 45 |
+
<CardHeader><CardTitle className="flex items-center gap-2"><Calendar className="h-5 w-5 text-blue-400" />Crear Publicación</CardTitle></CardHeader>
|
| 46 |
+
<CardContent className="space-y-4">
|
| 47 |
+
<Input placeholder="Título" value={postTitle} onChange={(e) => setPostTitle(e.target.value)} className="bg-slate-800 border-slate-700" />
|
| 48 |
+
<Textarea placeholder="Caption..." value={postCaption} onChange={(e) => setPostCaption(e.target.value)} className="bg-slate-800 border-slate-700 min-h-20" />
|
| 49 |
+
<div className="grid grid-cols-2 gap-4">
|
| 50 |
+
<div>
|
| 51 |
+
<Label className="text-xs text-slate-400">Tipo</Label>
|
| 52 |
+
<Select value={postType} onValueChange={setPostType}>
|
| 53 |
+
<SelectTrigger className="bg-slate-800 border-slate-700 mt-1"><SelectValue /></SelectTrigger>
|
| 54 |
+
<SelectContent>
|
| 55 |
+
<SelectItem value="reel">Reel</SelectItem>
|
| 56 |
+
<SelectItem value="photo">Foto</SelectItem>
|
| 57 |
+
<SelectItem value="carousel">Carrusel</SelectItem>
|
| 58 |
+
<SelectItem value="story">Story</SelectItem>
|
| 59 |
+
</SelectContent>
|
| 60 |
+
</Select>
|
| 61 |
+
</div>
|
| 62 |
+
<div>
|
| 63 |
+
<Label className="text-xs text-slate-400">Programar</Label>
|
| 64 |
+
<Input type="datetime-local" value={scheduledTime} onChange={(e) => setScheduledTime(e.target.value)} className="bg-slate-800 border-slate-700 mt-1" />
|
| 65 |
+
</div>
|
| 66 |
+
</div>
|
| 67 |
+
<Button onClick={handleCreatePost} className="w-full bg-blue-600 hover:bg-blue-700">
|
| 68 |
+
<Calendar className="h-4 w-4 mr-2" />{scheduledTime ? "Programar" : "Crear"}
|
| 69 |
+
</Button>
|
| 70 |
+
</CardContent>
|
| 71 |
+
</Card>
|
| 72 |
+
|
| 73 |
+
<Card className="bg-slate-900/50 border-slate-800">
|
| 74 |
+
<CardHeader><CardTitle>Publicaciones</CardTitle></CardHeader>
|
| 75 |
+
<CardContent>
|
| 76 |
+
<ScrollArea className="h-64">
|
| 77 |
+
{posts.length === 0 ? (
|
| 78 |
+
<p className="text-slate-400 text-center py-8">No hay publicaciones</p>
|
| 79 |
+
) : (
|
| 80 |
+
<div className="space-y-2">
|
| 81 |
+
{posts.map((p) => (
|
| 82 |
+
<div key={p.id} className="p-3 rounded-lg bg-slate-800/50 flex items-center justify-between">
|
| 83 |
+
<div>
|
| 84 |
+
<p className="font-medium text-sm">{p.title || "Sin título"}</p>
|
| 85 |
+
<p className="text-xs text-slate-400">{p.type} • {p.scheduledAt ? new Date(p.scheduledAt).toLocaleString() : "Borrador"}</p>
|
| 86 |
+
</div>
|
| 87 |
+
<Badge className={getStatusColor(p.status)}>{p.status}</Badge>
|
| 88 |
+
</div>
|
| 89 |
+
))}
|
| 90 |
+
</div>
|
| 91 |
+
)}
|
| 92 |
+
</ScrollArea>
|
| 93 |
+
</CardContent>
|
| 94 |
+
</Card>
|
| 95 |
+
</div>
|
| 96 |
+
);
|
| 97 |
+
}
|
src/components/dashboard/PromptEngineerTab.tsx
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
|
| 3 |
+
import { useState } from "react";
|
| 4 |
+
import { Wand2, ImageIcon, Calendar, RefreshCw, Copy } from "lucide-react";
|
| 5 |
+
import { Button } from "@/components/ui/button";
|
| 6 |
+
import { Textarea } from "@/components/ui/textarea";
|
| 7 |
+
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
| 8 |
+
import { Badge } from "@/components/ui/badge";
|
| 9 |
+
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
| 10 |
+
import { Label } from "@/components/ui/label";
|
| 11 |
+
import { Switch } from "@/components/ui/switch";
|
| 12 |
+
import { toast } from "sonner";
|
| 13 |
+
import { apiFetch, copyToClipboard, type Pet } from "./types";
|
| 14 |
+
|
| 15 |
+
interface Props {
|
| 16 |
+
pets: Pet[];
|
| 17 |
+
includePetInContent: boolean;
|
| 18 |
+
setIncludePetInContent: (v: boolean) => void;
|
| 19 |
+
onImageGenerated: () => void;
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
+
export default function PromptEngineerTab({ pets, includePetInContent, setIncludePetInContent, onImageGenerated }: Props) {
|
| 23 |
+
const [userPrompt, setUserPrompt] = useState("");
|
| 24 |
+
const [promptType, setPromptType] = useState("image");
|
| 25 |
+
const [targetPlatform, setTargetPlatform] = useState("general");
|
| 26 |
+
const [optimizedPrompt, setOptimizedPrompt] = useState<any | null>(null);
|
| 27 |
+
const [promptLoading, setPromptLoading] = useState(false);
|
| 28 |
+
const [imageLoading, setImageLoading] = useState(false);
|
| 29 |
+
|
| 30 |
+
const handleOptimizePrompt = async () => {
|
| 31 |
+
if (!userPrompt.trim()) { toast.error("Escribe un prompt"); return; }
|
| 32 |
+
setPromptLoading(true);
|
| 33 |
+
try {
|
| 34 |
+
const result = await apiFetch("/prompt-engineer", {
|
| 35 |
+
method: "POST",
|
| 36 |
+
body: JSON.stringify({ prompt: userPrompt, type: promptType, platform: targetPlatform }),
|
| 37 |
+
});
|
| 38 |
+
if (result.success) {
|
| 39 |
+
setOptimizedPrompt(result);
|
| 40 |
+
toast.success("Prompt optimizado");
|
| 41 |
+
} else toast.error(result.error);
|
| 42 |
+
} catch { toast.error("Error"); }
|
| 43 |
+
finally { setPromptLoading(false); }
|
| 44 |
+
};
|
| 45 |
+
|
| 46 |
+
const handleGenerateImage = async () => {
|
| 47 |
+
if (!userPrompt.trim()) { toast.error("Escribe un prompt"); return; }
|
| 48 |
+
setImageLoading(true);
|
| 49 |
+
try {
|
| 50 |
+
const result = await apiFetch("/generate/image", {
|
| 51 |
+
method: "POST",
|
| 52 |
+
body: JSON.stringify({
|
| 53 |
+
prompt: userPrompt,
|
| 54 |
+
optimizedPrompt: optimizedPrompt?.optimizedPrompt,
|
| 55 |
+
platform: targetPlatform,
|
| 56 |
+
style: "realistic",
|
| 57 |
+
includePet: includePetInContent,
|
| 58 |
+
petId: pets[0]?.id
|
| 59 |
+
}),
|
| 60 |
+
});
|
| 61 |
+
if (result.success) {
|
| 62 |
+
toast.success("Imagen generada");
|
| 63 |
+
onImageGenerated();
|
| 64 |
+
} else toast.error(result.error);
|
| 65 |
+
} catch { toast.error("Error"); }
|
| 66 |
+
finally { setImageLoading(false); }
|
| 67 |
+
};
|
| 68 |
+
|
| 69 |
+
return (
|
| 70 |
+
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
| 71 |
+
<Card className="bg-slate-900/50 border-slate-800">
|
| 72 |
+
<CardHeader>
|
| 73 |
+
<CardTitle className="flex items-center gap-2"><Wand2 className="h-5 w-5 text-violet-400" />Ingeniero de Prompts</CardTitle>
|
| 74 |
+
<CardDescription>Describe en lenguaje natural lo que quieres crear</CardDescription>
|
| 75 |
+
</CardHeader>
|
| 76 |
+
<CardContent className="space-y-4">
|
| 77 |
+
<Textarea placeholder="Ej: Quiero fotos de una mujer rubia en la playa para OnlyFans..." value={userPrompt} onChange={(e) => setUserPrompt(e.target.value)} className="bg-slate-800 border-slate-700 min-h-28" />
|
| 78 |
+
<div className="grid grid-cols-2 gap-4">
|
| 79 |
+
<div>
|
| 80 |
+
<Label className="text-xs text-slate-400">Tipo</Label>
|
| 81 |
+
<Select value={promptType} onValueChange={setPromptType}>
|
| 82 |
+
<SelectTrigger className="bg-slate-800 border-slate-700 mt-1"><SelectValue /></SelectTrigger>
|
| 83 |
+
<SelectContent>
|
| 84 |
+
<SelectItem value="image">Imagen</SelectItem>
|
| 85 |
+
<SelectItem value="video">Video</SelectItem>
|
| 86 |
+
<SelectItem value="reel">Reel</SelectItem>
|
| 87 |
+
<SelectItem value="carousel">Carrusel</SelectItem>
|
| 88 |
+
</SelectContent>
|
| 89 |
+
</Select>
|
| 90 |
+
</div>
|
| 91 |
+
<div>
|
| 92 |
+
<Label className="text-xs text-slate-400">Plataforma</Label>
|
| 93 |
+
<Select value={targetPlatform} onValueChange={setTargetPlatform}>
|
| 94 |
+
<SelectTrigger className="bg-slate-800 border-slate-700 mt-1"><SelectValue /></SelectTrigger>
|
| 95 |
+
<SelectContent>
|
| 96 |
+
<SelectItem value="general">General</SelectItem>
|
| 97 |
+
<SelectItem value="onlyfans">OnlyFans</SelectItem>
|
| 98 |
+
<SelectItem value="patreon">Patreon</SelectItem>
|
| 99 |
+
<SelectItem value="instagram">Instagram</SelectItem>
|
| 100 |
+
<SelectItem value="tiktok">TikTok</SelectItem>
|
| 101 |
+
<SelectItem value="youtube">YouTube</SelectItem>
|
| 102 |
+
</SelectContent>
|
| 103 |
+
</Select>
|
| 104 |
+
</div>
|
| 105 |
+
</div>
|
| 106 |
+
{pets.length > 0 && (
|
| 107 |
+
<div className="flex items-center gap-2">
|
| 108 |
+
<Switch checked={includePetInContent} onCheckedChange={setIncludePetInContent} />
|
| 109 |
+
<Label className="text-sm text-slate-400">Incluir mascota en el contenido (+35% engagement)</Label>
|
| 110 |
+
</div>
|
| 111 |
+
)}
|
| 112 |
+
<Button onClick={handleOptimizePrompt} disabled={promptLoading} className="w-full bg-violet-600 hover:bg-violet-700">
|
| 113 |
+
{promptLoading ? <RefreshCw className="h-4 w-4 mr-2 animate-spin" /> : <Wand2 className="h-4 w-4 mr-2" />}Optimizar
|
| 114 |
+
</Button>
|
| 115 |
+
</CardContent>
|
| 116 |
+
</Card>
|
| 117 |
+
|
| 118 |
+
<Card className="bg-slate-900/50 border-slate-800">
|
| 119 |
+
<CardHeader><CardTitle>Prompt Optimizado</CardTitle></CardHeader>
|
| 120 |
+
<CardContent>
|
| 121 |
+
{optimizedPrompt ? (
|
| 122 |
+
<div className="space-y-3">
|
| 123 |
+
<div className="p-3 rounded-lg bg-slate-800/50 border border-slate-700">
|
| 124 |
+
<div className="flex justify-between mb-2">
|
| 125 |
+
<Badge className="bg-violet-500/20 text-violet-400">{String(optimizedPrompt.type)}</Badge>
|
| 126 |
+
<Button variant="ghost" size="sm" onClick={() => copyToClipboard(String(optimizedPrompt.optimizedPrompt))}><Copy className="h-4 w-4" /></Button>
|
| 127 |
+
</div>
|
| 128 |
+
<p className="text-sm whitespace-pre-wrap">{String(optimizedPrompt.optimizedPrompt)}</p>
|
| 129 |
+
</div>
|
| 130 |
+
<div className="flex gap-2">
|
| 131 |
+
<Button onClick={handleGenerateImage} disabled={imageLoading} className="flex-1 bg-green-600 hover:bg-green-700">
|
| 132 |
+
<ImageIcon className="h-4 w-4 mr-2" />Generar Imagen
|
| 133 |
+
</Button>
|
| 134 |
+
<Button onClick={() => { }} className="flex-1 bg-blue-600 hover:bg-blue-700">
|
| 135 |
+
<Calendar className="h-4 w-4 mr-2" />Crear Post
|
| 136 |
+
</Button>
|
| 137 |
+
</div>
|
| 138 |
+
</div>
|
| 139 |
+
) : (
|
| 140 |
+
<div className="text-center py-12 text-slate-400">
|
| 141 |
+
<Wand2 className="h-12 w-12 mx-auto mb-3 opacity-50" /><p>El prompt optimizado aparecerá aquí</p>
|
| 142 |
+
</div>
|
| 143 |
+
)}
|
| 144 |
+
</CardContent>
|
| 145 |
+
</Card>
|
| 146 |
+
</div>
|
| 147 |
+
);
|
| 148 |
+
}
|
src/components/dashboard/StorytellingTab.tsx
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
|
| 3 |
+
import { useState } from "react";
|
| 4 |
+
import { Film, Sparkles, RefreshCw } from "lucide-react";
|
| 5 |
+
import { Button } from "@/components/ui/button";
|
| 6 |
+
import { Input } from "@/components/ui/input";
|
| 7 |
+
import { Textarea } from "@/components/ui/textarea";
|
| 8 |
+
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
| 9 |
+
import { Badge } from "@/components/ui/badge";
|
| 10 |
+
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
| 11 |
+
import { ScrollArea } from "@/components/ui/scroll-area";
|
| 12 |
+
import { Label } from "@/components/ui/label";
|
| 13 |
+
import { toast } from "sonner";
|
| 14 |
+
import { apiFetch, getStatusColor, type Story } from "./types";
|
| 15 |
+
|
| 16 |
+
interface Props { stories: Story[]; onCreated: () => void; }
|
| 17 |
+
|
| 18 |
+
export default function StorytellingTab({ stories, onCreated }: Props) {
|
| 19 |
+
const [storyPrompt, setStoryPrompt] = useState("");
|
| 20 |
+
const [storyGenre, setStoryGenre] = useState("lifestyle");
|
| 21 |
+
const [storyEpisodes, setStoryEpisodes] = useState(7);
|
| 22 |
+
const [storyLoading, setStoryLoading] = useState(false);
|
| 23 |
+
|
| 24 |
+
const handleCreateStory = async () => {
|
| 25 |
+
if (!storyPrompt.trim()) { toast.error("Describe tu historia"); return; }
|
| 26 |
+
setStoryLoading(true);
|
| 27 |
+
try {
|
| 28 |
+
const result = await apiFetch("/storytelling", {
|
| 29 |
+
method: "POST",
|
| 30 |
+
body: JSON.stringify({ prompt: storyPrompt, genre: storyGenre, totalEpisodes: storyEpisodes }),
|
| 31 |
+
});
|
| 32 |
+
if (result.success) {
|
| 33 |
+
toast.success(`Historia "${result.story?.title}" creada`);
|
| 34 |
+
setStoryPrompt("");
|
| 35 |
+
onCreated();
|
| 36 |
+
} else toast.error(result.error);
|
| 37 |
+
} catch { toast.error("Error"); }
|
| 38 |
+
finally { setStoryLoading(false); }
|
| 39 |
+
};
|
| 40 |
+
|
| 41 |
+
return (
|
| 42 |
+
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
| 43 |
+
<Card className="bg-slate-900/50 border-slate-800">
|
| 44 |
+
<CardHeader><CardTitle className="flex items-center gap-2"><Film className="h-5 w-5 text-purple-400" />Crear Historia</CardTitle></CardHeader>
|
| 45 |
+
<CardContent className="space-y-4">
|
| 46 |
+
<Textarea placeholder="Describe tu historia... Ej: Una historia de transformación fitness de 30 días..." value={storyPrompt} onChange={(e) => setStoryPrompt(e.target.value)} className="bg-slate-800 border-slate-700 min-h-24" />
|
| 47 |
+
<div className="grid grid-cols-2 gap-4">
|
| 48 |
+
<div>
|
| 49 |
+
<Label className="text-xs text-slate-400">Género</Label>
|
| 50 |
+
<Select value={storyGenre} onValueChange={setStoryGenre}>
|
| 51 |
+
<SelectTrigger className="bg-slate-800 border-slate-700 mt-1"><SelectValue /></SelectTrigger>
|
| 52 |
+
<SelectContent>
|
| 53 |
+
<SelectItem value="lifestyle">Lifestyle</SelectItem>
|
| 54 |
+
<SelectItem value="fitness">Fitness</SelectItem>
|
| 55 |
+
<SelectItem value="romance">Romance</SelectItem>
|
| 56 |
+
<SelectItem value="drama">Drama</SelectItem>
|
| 57 |
+
<SelectItem value="comedy">Comedia</SelectItem>
|
| 58 |
+
</SelectContent>
|
| 59 |
+
</Select>
|
| 60 |
+
</div>
|
| 61 |
+
<div>
|
| 62 |
+
<Label className="text-xs text-slate-400">Episodios</Label>
|
| 63 |
+
<Input type="number" value={storyEpisodes} onChange={(e) => setStoryEpisodes(parseInt(e.target.value) || 7)} className="bg-slate-800 border-slate-700 mt-1" />
|
| 64 |
+
</div>
|
| 65 |
+
</div>
|
| 66 |
+
<Button onClick={handleCreateStory} disabled={storyLoading} className="w-full bg-purple-600 hover:bg-purple-700">
|
| 67 |
+
{storyLoading ? <RefreshCw className="h-4 w-4 mr-2 animate-spin" /> : <Sparkles className="h-4 w-4 mr-2" />}Generar Historia
|
| 68 |
+
</Button>
|
| 69 |
+
</CardContent>
|
| 70 |
+
</Card>
|
| 71 |
+
|
| 72 |
+
<Card className="bg-slate-900/50 border-slate-800">
|
| 73 |
+
<CardHeader><CardTitle>Historias Creadas</CardTitle></CardHeader>
|
| 74 |
+
<CardContent>
|
| 75 |
+
<ScrollArea className="h-64">
|
| 76 |
+
{stories.length === 0 ? (
|
| 77 |
+
<p className="text-slate-400 text-center py-8">No hay historias</p>
|
| 78 |
+
) : (
|
| 79 |
+
<div className="space-y-2">
|
| 80 |
+
{stories.map((s) => (
|
| 81 |
+
<div key={s.id} className="p-3 rounded-lg bg-slate-800/50">
|
| 82 |
+
<div className="flex justify-between items-start">
|
| 83 |
+
<div>
|
| 84 |
+
<p className="font-medium">{s.title}</p>
|
| 85 |
+
<p className="text-xs text-slate-400">{s.totalEpisodes} episodios • {s.genre}</p>
|
| 86 |
+
</div>
|
| 87 |
+
<Badge className={getStatusColor(s.status)}>{s.status}</Badge>
|
| 88 |
+
</div>
|
| 89 |
+
</div>
|
| 90 |
+
))}
|
| 91 |
+
</div>
|
| 92 |
+
)}
|
| 93 |
+
</ScrollArea>
|
| 94 |
+
</CardContent>
|
| 95 |
+
</Card>
|
| 96 |
+
</div>
|
| 97 |
+
);
|
| 98 |
+
}
|
src/components/dashboard/TrendsTab.tsx
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
|
| 3 |
+
import { useState, useEffect, useCallback } from "react";
|
| 4 |
+
import { Rocket, Flame, Target, Lightbulb, RefreshCw } from "lucide-react";
|
| 5 |
+
import { Button } from "@/components/ui/button";
|
| 6 |
+
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
| 7 |
+
import { Badge } from "@/components/ui/badge";
|
| 8 |
+
import { Label } from "@/components/ui/label";
|
| 9 |
+
import { Switch } from "@/components/ui/switch";
|
| 10 |
+
import { toast } from "sonner";
|
| 11 |
+
import { apiFetch } from "./types";
|
| 12 |
+
|
| 13 |
+
export default function TrendsTab() {
|
| 14 |
+
const [trends, setTrends] = useState<any[]>([]);
|
| 15 |
+
const [viralStrategies, setViralStrategies] = useState<any[]>([]);
|
| 16 |
+
const [contentIdeas, setContentIdeas] = useState<any[]>([]);
|
| 17 |
+
const [trendAnalysis, setTrendAnalysis] = useState<any | null>(null);
|
| 18 |
+
const [trendLoading, setTrendLoading] = useState(false);
|
| 19 |
+
const [includePetInContent, setIncludePetInContent] = useState(false);
|
| 20 |
+
|
| 21 |
+
const loadTrends = useCallback(async () => {
|
| 22 |
+
try {
|
| 23 |
+
const result = await apiFetch(`/trends?includePets=${includePetInContent}`);
|
| 24 |
+
if (result.success) {
|
| 25 |
+
setTrends(result.trends);
|
| 26 |
+
setViralStrategies(result.viralStrategies);
|
| 27 |
+
setContentIdeas(result.contentIdeas);
|
| 28 |
+
}
|
| 29 |
+
} catch { toast.error("Error cargando tendencias"); }
|
| 30 |
+
}, [includePetInContent]);
|
| 31 |
+
|
| 32 |
+
useEffect(() => { loadTrends(); }, [loadTrends]);
|
| 33 |
+
|
| 34 |
+
const handleAnalyzeTrends = async () => {
|
| 35 |
+
setTrendLoading(true);
|
| 36 |
+
try {
|
| 37 |
+
const result = await apiFetch("/trends", {
|
| 38 |
+
method: "POST",
|
| 39 |
+
body: JSON.stringify({
|
| 40 |
+
niche: "lifestyle",
|
| 41 |
+
includePets: includePetInContent,
|
| 42 |
+
daysToViral: 14
|
| 43 |
+
}),
|
| 44 |
+
});
|
| 45 |
+
if (result.success) {
|
| 46 |
+
setTrendAnalysis(result.analysis);
|
| 47 |
+
toast.success("Análisis de tendencias completado");
|
| 48 |
+
} else toast.error(result.error);
|
| 49 |
+
} catch { toast.error("Error"); }
|
| 50 |
+
finally { setTrendLoading(false); }
|
| 51 |
+
};
|
| 52 |
+
|
| 53 |
+
return (
|
| 54 |
+
<div className="space-y-6">
|
| 55 |
+
<div className="flex items-center gap-4">
|
| 56 |
+
<div className="flex items-center gap-2">
|
| 57 |
+
<Switch checked={includePetInContent} onCheckedChange={setIncludePetInContent} />
|
| 58 |
+
<Label className="text-sm text-slate-400">Incluir tendencias de mascotas</Label>
|
| 59 |
+
</div>
|
| 60 |
+
<Button onClick={handleAnalyzeTrends} disabled={trendLoading} className="bg-violet-600 hover:bg-violet-700">
|
| 61 |
+
{trendLoading ? <RefreshCw className="h-4 w-4 mr-2 animate-spin" /> : <Rocket className="h-4 w-4 mr-2" />}Analizar para Viralizar
|
| 62 |
+
</Button>
|
| 63 |
+
</div>
|
| 64 |
+
|
| 65 |
+
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
| 66 |
+
{trends.slice(0, 8).map((t, i) => (
|
| 67 |
+
<Card key={i} className="bg-slate-900/50 border-slate-800">
|
| 68 |
+
<CardContent className="p-4">
|
| 69 |
+
<div className="flex items-center justify-between mb-2">
|
| 70 |
+
<Badge variant="outline" className="text-xs">{String(t.platform || "")}</Badge>
|
| 71 |
+
<Flame className={`h-4 w-4 ${Number(t.growth) > 30 ? "text-red-400" : "text-orange-400"}`} />
|
| 72 |
+
</div>
|
| 73 |
+
<p className="font-semibold">{String(t.name)}</p>
|
| 74 |
+
<p className="text-green-400 text-sm">+{Number(t.growth).toFixed(1)}%</p>
|
| 75 |
+
<p className="text-xs text-slate-400 mt-1">{String(t.category || "")}</p>
|
| 76 |
+
</CardContent>
|
| 77 |
+
</Card>
|
| 78 |
+
))}
|
| 79 |
+
</div>
|
| 80 |
+
|
| 81 |
+
{viralStrategies.length > 0 && (
|
| 82 |
+
<Card className="bg-slate-900/50 border-slate-800">
|
| 83 |
+
<CardHeader><CardTitle className="flex items-center gap-2"><Rocket className="h-5 w-5 text-violet-400" />Estrategias Virales</CardTitle></CardHeader>
|
| 84 |
+
<CardContent>
|
| 85 |
+
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
| 86 |
+
{viralStrategies.slice(0, 4).map((s, i) => (
|
| 87 |
+
<div key={i} className="p-3 rounded-lg bg-slate-800/50 border border-slate-700">
|
| 88 |
+
<div className="flex items-center justify-between mb-2">
|
| 89 |
+
<p className="font-medium text-sm">{String(s.name)}</p>
|
| 90 |
+
<Badge className="bg-green-500/20 text-green-400 text-xs">{String(s.successRate)}%</Badge>
|
| 91 |
+
</div>
|
| 92 |
+
<p className="text-xs text-slate-400 line-clamp-2">{String(s.description)}</p>
|
| 93 |
+
<div className="flex flex-wrap gap-1 mt-2">
|
| 94 |
+
{Array.isArray(s.platforms) && s.platforms.slice(0, 3).map((p: string, j: number) => (
|
| 95 |
+
<Badge key={j} variant="outline" className="text-xs">{p}</Badge>
|
| 96 |
+
))}
|
| 97 |
+
</div>
|
| 98 |
+
</div>
|
| 99 |
+
))}
|
| 100 |
+
</div>
|
| 101 |
+
</CardContent>
|
| 102 |
+
</Card>
|
| 103 |
+
)}
|
| 104 |
+
|
| 105 |
+
{contentIdeas.length > 0 && (
|
| 106 |
+
<Card className="bg-slate-900/50 border-slate-800">
|
| 107 |
+
<CardHeader><CardTitle className="flex items-center gap-2"><Lightbulb className="h-5 w-5 text-amber-400" />Ideas de Contenido Viral</CardTitle></CardHeader>
|
| 108 |
+
<CardContent>
|
| 109 |
+
<div className="space-y-3">
|
| 110 |
+
{contentIdeas.slice(0, 5).map((idea, i) => (
|
| 111 |
+
<div key={i} className="p-3 rounded-lg bg-slate-800/50 border border-slate-700">
|
| 112 |
+
<div className="flex items-center justify-between">
|
| 113 |
+
<div>
|
| 114 |
+
<p className="font-medium">{String(idea.title)}</p>
|
| 115 |
+
<p className="text-xs text-slate-400 mt-1">{String(idea.description)}</p>
|
| 116 |
+
{idea.hook && <p className="text-xs text-violet-400 mt-1">Hook: "{String(idea.hook)}"</p>}
|
| 117 |
+
</div>
|
| 118 |
+
<div className="text-right">
|
| 119 |
+
<Badge className="bg-violet-500/20 text-violet-400">{String(idea.format)}</Badge>
|
| 120 |
+
{idea.viralScore && <p className="text-xs text-green-400 mt-1">Score: {String(idea.viralScore)}</p>}
|
| 121 |
+
</div>
|
| 122 |
+
</div>
|
| 123 |
+
</div>
|
| 124 |
+
))}
|
| 125 |
+
</div>
|
| 126 |
+
</CardContent>
|
| 127 |
+
</Card>
|
| 128 |
+
)}
|
| 129 |
+
|
| 130 |
+
{trendAnalysis && (
|
| 131 |
+
<Card className="bg-slate-900/50 border-slate-800 border-violet-500/30">
|
| 132 |
+
<CardHeader>
|
| 133 |
+
<CardTitle className="flex items-center gap-2 text-violet-400"><Target className="h-5 w-5" />Plan para Viralizar</CardTitle>
|
| 134 |
+
</CardHeader>
|
| 135 |
+
<CardContent>
|
| 136 |
+
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
| 137 |
+
<div>
|
| 138 |
+
<h4 className="font-medium mb-2">Tendencias Emergentes</h4>
|
| 139 |
+
<div className="space-y-2">
|
| 140 |
+
{Array.isArray(trendAnalysis.emergingTrends) && trendAnalysis.emergingTrends.slice(0, 3).map((t: Record<string, unknown>, i: number) => (
|
| 141 |
+
<div key={i} className="p-2 rounded bg-slate-800/50 text-sm">
|
| 142 |
+
<p className="font-medium">{String(t.name)}</p>
|
| 143 |
+
<p className="text-xs text-slate-400">Potencial: {String(t.potential)}</p>
|
| 144 |
+
</div>
|
| 145 |
+
))}
|
| 146 |
+
</div>
|
| 147 |
+
</div>
|
| 148 |
+
<div>
|
| 149 |
+
<h4 className="font-medium mb-2">Recomendaciones</h4>
|
| 150 |
+
<div className="space-y-1">
|
| 151 |
+
{Array.isArray(trendAnalysis.recommendations) && trendAnalysis.recommendations.slice(0, 4).map((r: string, i: number) => (
|
| 152 |
+
<p key={i} className="text-sm text-slate-300">• {r}</p>
|
| 153 |
+
))}
|
| 154 |
+
</div>
|
| 155 |
+
{trendAnalysis.predictedViralPotential && (
|
| 156 |
+
<div className="mt-4 p-3 rounded-lg bg-violet-500/10 border border-violet-500/30">
|
| 157 |
+
<p className="text-sm">Potencial Viral Estimado:</p>
|
| 158 |
+
<p className="text-2xl font-bold text-violet-400">{String(trendAnalysis.predictedViralPotential)}%</p>
|
| 159 |
+
</div>
|
| 160 |
+
)}
|
| 161 |
+
</div>
|
| 162 |
+
</div>
|
| 163 |
+
</CardContent>
|
| 164 |
+
</Card>
|
| 165 |
+
)}
|
| 166 |
+
</div>
|
| 167 |
+
);
|
| 168 |
+
}
|
src/components/dashboard/VideosTab.tsx
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
|
| 3 |
+
import { useState } from "react";
|
| 4 |
+
import { Video, RefreshCw } from "lucide-react";
|
| 5 |
+
import { Button } from "@/components/ui/button";
|
| 6 |
+
import { Textarea } from "@/components/ui/textarea";
|
| 7 |
+
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
| 8 |
+
import { toast } from "sonner";
|
| 9 |
+
import { apiFetch } from "./types";
|
| 10 |
+
|
| 11 |
+
interface Props { onGenerated: () => void; }
|
| 12 |
+
|
| 13 |
+
export default function VideosTab({ onGenerated }: Props) {
|
| 14 |
+
const [userPrompt, setUserPrompt] = useState("");
|
| 15 |
+
const [loading, setLoading] = useState(false);
|
| 16 |
+
|
| 17 |
+
const handleGenerateVideo = async () => {
|
| 18 |
+
if (!userPrompt.trim()) { toast.error("Describe el video"); return; }
|
| 19 |
+
setLoading(true);
|
| 20 |
+
try {
|
| 21 |
+
const result = await apiFetch("/generate/video", {
|
| 22 |
+
method: "POST",
|
| 23 |
+
body: JSON.stringify({ prompt: userPrompt }),
|
| 24 |
+
});
|
| 25 |
+
if (result.success) {
|
| 26 |
+
toast.success("Video generado");
|
| 27 |
+
onGenerated();
|
| 28 |
+
} else toast.error(result.error);
|
| 29 |
+
} catch { toast.error("Error"); }
|
| 30 |
+
finally { setLoading(false); }
|
| 31 |
+
};
|
| 32 |
+
|
| 33 |
+
return (
|
| 34 |
+
<Card className="bg-slate-900/50 border-slate-800">
|
| 35 |
+
<CardHeader><CardTitle className="flex items-center gap-2"><Video className="h-5 w-5 text-blue-400" />Generar Videos</CardTitle></CardHeader>
|
| 36 |
+
<CardContent className="space-y-4">
|
| 37 |
+
<Textarea placeholder="Describe el video..." value={userPrompt} onChange={(e) => setUserPrompt(e.target.value)} className="bg-slate-800 border-slate-700" />
|
| 38 |
+
<Button onClick={handleGenerateVideo} disabled={loading} className="w-full bg-blue-600 hover:bg-blue-700">
|
| 39 |
+
{loading ? <RefreshCw className="h-4 w-4 mr-2 animate-spin" /> : <Video className="h-4 w-4 mr-2" />}Generar Video
|
| 40 |
+
</Button>
|
| 41 |
+
</CardContent>
|
| 42 |
+
</Card>
|
| 43 |
+
);
|
| 44 |
+
}
|
src/components/dashboard/index.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
export { default as DashboardSidebar } from "./DashboardSidebar";
|
| 2 |
+
export { default as PromptEngineerTab } from "./PromptEngineerTab";
|
| 3 |
+
export { default as ImagesTab } from "./ImagesTab";
|
| 4 |
+
export { default as VideosTab } from "./VideosTab";
|
| 5 |
+
export { default as MonetizationTab } from "./MonetizationTab";
|
| 6 |
+
export { default as PostsTab } from "./PostsTab";
|
| 7 |
+
export { default as StorytellingTab } from "./StorytellingTab";
|
| 8 |
+
export { default as AutomationTab } from "./AutomationTab";
|
| 9 |
+
export { default as InfluencersTab } from "./InfluencersTab";
|
| 10 |
+
export { default as PetsTab } from "./PetsTab";
|
| 11 |
+
export { default as TrendsTab } from "./TrendsTab";
|
| 12 |
+
export { default as ContentTab } from "./ContentTab";
|
src/components/dashboard/types.ts
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
|
| 3 |
+
import { toast } from "sonner";
|
| 4 |
+
|
| 5 |
+
// ============================================
|
| 6 |
+
// Types
|
| 7 |
+
// ============================================
|
| 8 |
+
|
| 9 |
+
export interface Content { id: string; type: string; title: string; status: string; platform: string; createdAt: string; }
|
| 10 |
+
export interface Character { id: string; name: string; description: string | null; referenceImage: string | null; }
|
| 11 |
+
export interface Platform { id: string; name: string; type: string; isActive: boolean; isVerified: boolean; }
|
| 12 |
+
export interface Post { id: string; title: string | null; type: string; status: string; scheduledAt: string | null; }
|
| 13 |
+
export interface Story { id: string; title: string; genre: string; status: string; totalEpisodes: number; }
|
| 14 |
+
export interface Automation { id: string; name: string; type: string; isActive: boolean; runCount: number; }
|
| 15 |
+
export interface Pet { id: string; name: string; type: string; breed: string | null; personality: string | null; referenceImage: string | null; }
|
| 16 |
+
export interface Influencer { name: string; handle: string; platform: string; followers: number; engagement: number; niche: string; petCompanion: boolean; petType?: string; keyLessons: string[]; }
|
| 17 |
+
|
| 18 |
+
// ============================================
|
| 19 |
+
// API Helper
|
| 20 |
+
// ============================================
|
| 21 |
+
|
| 22 |
+
export async function apiFetch(endpoint: string, options: RequestInit = {}) {
|
| 23 |
+
const response = await fetch(`/api${endpoint}`, {
|
| 24 |
+
headers: { "Content-Type": "application/json", ...options.headers },
|
| 25 |
+
...options,
|
| 26 |
+
});
|
| 27 |
+
return response.json();
|
| 28 |
+
}
|
| 29 |
+
|
| 30 |
+
// ============================================
|
| 31 |
+
// Helpers
|
| 32 |
+
// ============================================
|
| 33 |
+
|
| 34 |
+
export function getStatusColor(status: string) {
|
| 35 |
+
const colors: Record<string, string> = {
|
| 36 |
+
completed: "bg-green-500/10 text-green-500",
|
| 37 |
+
published: "bg-green-500/10 text-green-500",
|
| 38 |
+
scheduled: "bg-blue-500/10 text-blue-500",
|
| 39 |
+
draft: "bg-slate-500/10 text-slate-400",
|
| 40 |
+
pending: "bg-yellow-500/10 text-yellow-500",
|
| 41 |
+
active: "bg-green-500/10 text-green-500",
|
| 42 |
+
};
|
| 43 |
+
return colors[status] || "bg-slate-500/10 text-slate-400";
|
| 44 |
+
}
|
| 45 |
+
|
| 46 |
+
export function copyToClipboard(text: string) {
|
| 47 |
+
navigator.clipboard.writeText(text);
|
| 48 |
+
toast.success("Copiado");
|
| 49 |
+
}
|
src/lib/ai.ts
DELETED
|
@@ -1,54 +0,0 @@
|
|
| 1 |
-
// Shared AI helper — uses DeepSeek API (OpenAI-compatible format)
|
| 2 |
-
// Very cheap: ~$0.14 per million tokens
|
| 3 |
-
|
| 4 |
-
const CHAT_API_URL = process.env.CHAT_API_URL || "https://api.deepseek.com/v1/chat/completions";
|
| 5 |
-
const CHAT_API_KEY = process.env.CHAT_API_KEY || "";
|
| 6 |
-
const CHAT_MODEL = process.env.CHAT_MODEL || "deepseek-chat";
|
| 7 |
-
|
| 8 |
-
interface ChatMessage {
|
| 9 |
-
role: "system" | "user" | "assistant";
|
| 10 |
-
content: string;
|
| 11 |
-
}
|
| 12 |
-
|
| 13 |
-
interface ChatCompletionChoice {
|
| 14 |
-
message: { content: string | null };
|
| 15 |
-
}
|
| 16 |
-
|
| 17 |
-
interface ChatCompletionResponse {
|
| 18 |
-
choices: ChatCompletionChoice[];
|
| 19 |
-
}
|
| 20 |
-
|
| 21 |
-
interface CreateOptions {
|
| 22 |
-
messages: ChatMessage[];
|
| 23 |
-
temperature?: number;
|
| 24 |
-
max_tokens?: number;
|
| 25 |
-
}
|
| 26 |
-
|
| 27 |
-
export const ai = {
|
| 28 |
-
chat: {
|
| 29 |
-
completions: {
|
| 30 |
-
create: async (options: CreateOptions): Promise<ChatCompletionResponse> => {
|
| 31 |
-
const res = await fetch(CHAT_API_URL, {
|
| 32 |
-
method: "POST",
|
| 33 |
-
headers: {
|
| 34 |
-
"Authorization": `Bearer ${CHAT_API_KEY}`,
|
| 35 |
-
"Content-Type": "application/json",
|
| 36 |
-
},
|
| 37 |
-
body: JSON.stringify({
|
| 38 |
-
model: CHAT_MODEL,
|
| 39 |
-
messages: options.messages,
|
| 40 |
-
temperature: options.temperature ?? 0.7,
|
| 41 |
-
max_tokens: options.max_tokens ?? 1000,
|
| 42 |
-
}),
|
| 43 |
-
});
|
| 44 |
-
|
| 45 |
-
if (!res.ok) {
|
| 46 |
-
const errorText = await res.text();
|
| 47 |
-
throw new Error(`AI API error: ${res.status} ${errorText}`);
|
| 48 |
-
}
|
| 49 |
-
|
| 50 |
-
return res.json();
|
| 51 |
-
},
|
| 52 |
-
},
|
| 53 |
-
},
|
| 54 |
-
};
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
src/lib/credits.ts
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// Sistema de créditos por tier - PERMANENTES (se resetean solo si suben de nivel)
|
| 2 |
+
// Los límites no se resetean mensualmente, son parte de lo que pagan
|
| 3 |
+
// Se resetean SOLO si el cliente actualiza su suscripción a un tier superior
|
| 4 |
+
|
| 5 |
+
export const TIER_LIMITS = {
|
| 6 |
+
free: {
|
| 7 |
+
influencers_per_month: 2,
|
| 8 |
+
stories_per_month: 3,
|
| 9 |
+
images_per_month: 5,
|
| 10 |
+
videos_per_month: 1,
|
| 11 |
+
content_per_month: 10,
|
| 12 |
+
},
|
| 13 |
+
basic: {
|
| 14 |
+
influencers_per_month: 10,
|
| 15 |
+
stories_per_month: 15,
|
| 16 |
+
images_per_month: 30,
|
| 17 |
+
videos_per_month: 5,
|
| 18 |
+
content_per_month: 50,
|
| 19 |
+
},
|
| 20 |
+
premium: {
|
| 21 |
+
influencers_per_month: 50,
|
| 22 |
+
stories_per_month: 60,
|
| 23 |
+
images_per_month: 150,
|
| 24 |
+
videos_per_month: 20,
|
| 25 |
+
content_per_month: 300,
|
| 26 |
+
},
|
| 27 |
+
pro: {
|
| 28 |
+
influencers_per_month: 500,
|
| 29 |
+
stories_per_month: 999,
|
| 30 |
+
images_per_month: 999,
|
| 31 |
+
videos_per_month: 999,
|
| 32 |
+
content_per_month: 999,
|
| 33 |
+
},
|
| 34 |
+
};
|
| 35 |
+
|
| 36 |
+
export type Tier = keyof typeof TIER_LIMITS;
|
| 37 |
+
export type ResourceType = keyof (typeof TIER_LIMITS)["free"];
|
| 38 |
+
|
| 39 |
+
// Ranking de tiers para comparaciones
|
| 40 |
+
export const TIER_RANK: Record<Tier, number> = {
|
| 41 |
+
free: 0,
|
| 42 |
+
basic: 1,
|
| 43 |
+
premium: 2,
|
| 44 |
+
pro: 3,
|
| 45 |
+
};
|
| 46 |
+
|
| 47 |
+
// Comprueba si el usuario con `userTier` puede suscribirse a una suscripción de `subTier`.
|
| 48 |
+
// Regla: el usuario solo puede crear suscripciones con un tier <= su propio tier.
|
| 49 |
+
export function isSubscriptionTierAllowed(userTier: Tier | string, subTier: Tier | string) {
|
| 50 |
+
const u = (userTier as Tier) in TIER_RANK ? (userTier as Tier) : (userTier as Tier);
|
| 51 |
+
const s = (subTier as Tier) in TIER_RANK ? (subTier as Tier) : (subTier as Tier);
|
| 52 |
+
const ur = TIER_RANK[u as Tier] ?? 0;
|
| 53 |
+
const sr = TIER_RANK[s as Tier] ?? 0;
|
| 54 |
+
return ur >= sr;
|
| 55 |
+
}
|
| 56 |
+
|
| 57 |
+
// Validar si usuario puede hacer la acción
|
| 58 |
+
// Los límites son PERMANENTES para el tier contratado
|
| 59 |
+
// Se resetean solo si el usuario sube a un tier superior o solicita un reset manual
|
| 60 |
+
export async function validateUserCredit(
|
| 61 |
+
db: any,
|
| 62 |
+
userId: string,
|
| 63 |
+
resourceType: ResourceType,
|
| 64 |
+
tier: Tier
|
| 65 |
+
): Promise<{ allowed: boolean; reason?: string; remaining?: number }> {
|
| 66 |
+
const limit = TIER_LIMITS[tier][resourceType];
|
| 67 |
+
|
| 68 |
+
// Contar TODOS los recursos creados (sin límite de fecha)
|
| 69 |
+
// Son permanentes para este tier de suscripción
|
| 70 |
+
|
| 71 |
+
let used = 0;
|
| 72 |
+
|
| 73 |
+
try {
|
| 74 |
+
if (resourceType === "influencers_per_month") {
|
| 75 |
+
used = await db.aIInfluencer.count({
|
| 76 |
+
where: {
|
| 77 |
+
// Sin filtro de fecha - son permanentes
|
| 78 |
+
},
|
| 79 |
+
});
|
| 80 |
+
} else if (resourceType === "stories_per_month") {
|
| 81 |
+
used = await db.story.count({
|
| 82 |
+
where: {
|
| 83 |
+
// Sin filtro de fecha - son permanentes
|
| 84 |
+
},
|
| 85 |
+
});
|
| 86 |
+
} else if (resourceType === "images_per_month") {
|
| 87 |
+
used = await db.content.count({
|
| 88 |
+
where: {
|
| 89 |
+
type: "image",
|
| 90 |
+
// Sin filtro de fecha - son permanentes
|
| 91 |
+
},
|
| 92 |
+
});
|
| 93 |
+
} else if (resourceType === "videos_per_month") {
|
| 94 |
+
used = await db.content.count({
|
| 95 |
+
where: {
|
| 96 |
+
type: "video",
|
| 97 |
+
// Sin filtro de fecha - son permanentes
|
| 98 |
+
},
|
| 99 |
+
});
|
| 100 |
+
} else if (resourceType === "content_per_month") {
|
| 101 |
+
used = await db.content.count({
|
| 102 |
+
where: {
|
| 103 |
+
// Sin filtro de fecha - son permanentes
|
| 104 |
+
},
|
| 105 |
+
});
|
| 106 |
+
}
|
| 107 |
+
} catch {
|
| 108 |
+
// Si hay error, permitir (más seguro que bloquear)
|
| 109 |
+
used = 0;
|
| 110 |
+
}
|
| 111 |
+
|
| 112 |
+
const remaining = Math.max(0, limit - used);
|
| 113 |
+
|
| 114 |
+
return {
|
| 115 |
+
allowed: remaining > 0,
|
| 116 |
+
reason:
|
| 117 |
+
remaining === 0
|
| 118 |
+
? `Límite de ${resourceType} alcanzado (${limit}). Necesitas subir a un tier superior.`
|
| 119 |
+
: undefined,
|
| 120 |
+
remaining,
|
| 121 |
+
};
|
| 122 |
+
}
|
| 123 |
+
|
| 124 |
+
// Log de uso (opcional, para futuro tracking)
|
| 125 |
+
export async function logResourceUsage(
|
| 126 |
+
db: any,
|
| 127 |
+
userId: string,
|
| 128 |
+
resourceType: ResourceType,
|
| 129 |
+
resourceId: string
|
| 130 |
+
) {
|
| 131 |
+
try {
|
| 132 |
+
// Implementar cuando sea necesario
|
| 133 |
+
// await db.resourceUsage.create({...})
|
| 134 |
+
} catch {
|
| 135 |
+
// Ignorar errores de logging
|
| 136 |
+
}
|
| 137 |
+
}
|
| 138 |
+
|
src/lib/stripe.ts
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import Stripe from "stripe";
|
| 2 |
+
|
| 3 |
+
const STRIPE_KEY = process.env.STRIPE_SECRET_KEY || "";
|
| 4 |
+
const STRIPE_WEBHOOK_SECRET = process.env.STRIPE_WEBHOOK_SECRET || "";
|
| 5 |
+
|
| 6 |
+
export const stripe = new Stripe(STRIPE_KEY, { apiVersion: "2026-02-25.clover" });
|
| 7 |
+
|
| 8 |
+
// Crear sesión de checkout para suscripción
|
| 9 |
+
export async function createCheckoutSession({
|
| 10 |
+
userId,
|
| 11 |
+
influencerId,
|
| 12 |
+
influencerName,
|
| 13 |
+
tier,
|
| 14 |
+
price,
|
| 15 |
+
stripeCustomerId,
|
| 16 |
+
origin,
|
| 17 |
+
}: {
|
| 18 |
+
userId: string;
|
| 19 |
+
influencerId: string;
|
| 20 |
+
influencerName: string;
|
| 21 |
+
tier: string;
|
| 22 |
+
price: number;
|
| 23 |
+
stripeCustomerId?: string;
|
| 24 |
+
origin: string;
|
| 25 |
+
}): Promise<string> {
|
| 26 |
+
const session = await stripe.checkout.sessions.create({
|
| 27 |
+
customer: stripeCustomerId,
|
| 28 |
+
payment_method_types: ["card"],
|
| 29 |
+
mode: "subscription",
|
| 30 |
+
line_items: [
|
| 31 |
+
{
|
| 32 |
+
price_data: {
|
| 33 |
+
currency: "usd",
|
| 34 |
+
product_data: {
|
| 35 |
+
name: `Suscripción a ${influencerName} - ${tier}`,
|
| 36 |
+
description: `Acceso a contenido exclusivo de ${influencerName}`,
|
| 37 |
+
metadata: {
|
| 38 |
+
influencerId,
|
| 39 |
+
userId,
|
| 40 |
+
influencerName,
|
| 41 |
+
},
|
| 42 |
+
},
|
| 43 |
+
recurring: {
|
| 44 |
+
interval: "month",
|
| 45 |
+
interval_count: 1,
|
| 46 |
+
},
|
| 47 |
+
unit_amount: Math.round(price * 100), // Convert to cents
|
| 48 |
+
},
|
| 49 |
+
quantity: 1,
|
| 50 |
+
},
|
| 51 |
+
],
|
| 52 |
+
metadata: {
|
| 53 |
+
userId,
|
| 54 |
+
influencerId,
|
| 55 |
+
tier,
|
| 56 |
+
},
|
| 57 |
+
success_url: `${origin}/success?session_id={CHECKOUT_SESSION_ID}`,
|
| 58 |
+
cancel_url: `${origin}/cancel`,
|
| 59 |
+
});
|
| 60 |
+
|
| 61 |
+
return session.id;
|
| 62 |
+
}
|
| 63 |
+
|
| 64 |
+
// Validar firma del webhook
|
| 65 |
+
export function validateWebhookSignature(body: string, signature: string): boolean {
|
| 66 |
+
if (!STRIPE_WEBHOOK_SECRET) return false;
|
| 67 |
+
try {
|
| 68 |
+
const event = stripe.webhooks.constructEvent(body, signature, STRIPE_WEBHOOK_SECRET);
|
| 69 |
+
return true;
|
| 70 |
+
} catch {
|
| 71 |
+
return false;
|
| 72 |
+
}
|
| 73 |
+
}
|
| 74 |
+
|
| 75 |
+
// Parsear webhook event
|
| 76 |
+
export function parseWebhookEvent(body: string, signature: string) {
|
| 77 |
+
if (!STRIPE_WEBHOOK_SECRET) throw new Error("STRIPE_WEBHOOK_SECRET no configurado");
|
| 78 |
+
return stripe.webhooks.constructEvent(body, signature, STRIPE_WEBHOOK_SECRET);
|
| 79 |
+
}
|
| 80 |
+
|
| 81 |
+
// Crear o obtener cliente Stripe
|
| 82 |
+
export async function getOrCreateStripeCustomer(
|
| 83 |
+
userId: string,
|
| 84 |
+
email: string,
|
| 85 |
+
name?: string
|
| 86 |
+
): Promise<string> {
|
| 87 |
+
const customers = await stripe.customers.list({ email, limit: 1 });
|
| 88 |
+
if (customers.data.length > 0) return customers.data[0].id;
|
| 89 |
+
|
| 90 |
+
const customer = await stripe.customers.create({
|
| 91 |
+
email,
|
| 92 |
+
name: name || undefined,
|
| 93 |
+
metadata: { userId },
|
| 94 |
+
});
|
| 95 |
+
return customer.id;
|
| 96 |
+
}
|
| 97 |
+
|
| 98 |
+
// Cancelar suscripción en Stripe
|
| 99 |
+
export async function cancelStripeSubscription(stripeSubscriptionId: string) {
|
| 100 |
+
if (!stripeSubscriptionId) throw new Error("stripeSubscriptionId es requerido");
|
| 101 |
+
return stripe.subscriptions.cancel(stripeSubscriptionId);
|
| 102 |
+
}
|