@mbermeo/wa-blaster 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) hide show
  1. package/README.md +1060 -0
  2. package/bin/wa-blast.js +124 -0
  3. package/dist/node20/cli/index.js +2 -0
  4. package/dist/node20/cli/index.jsc +0 -0
  5. package/dist/node20/cli/lib.js +2 -0
  6. package/dist/node20/cli/lib.jsc +0 -0
  7. package/dist/node20/mcp/index.js +2 -0
  8. package/dist/node20/mcp/index.jsc +0 -0
  9. package/dist/node20/src/accountManager.js +2 -0
  10. package/dist/node20/src/accountManager.jsc +0 -0
  11. package/dist/node20/src/campaignRunner.js +2 -0
  12. package/dist/node20/src/campaignRunner.jsc +0 -0
  13. package/dist/node20/src/db.js +2 -0
  14. package/dist/node20/src/db.jsc +0 -0
  15. package/dist/node20/src/paths.js +2 -0
  16. package/dist/node20/src/paths.jsc +0 -0
  17. package/dist/node20/src/routes/accounts.js +2 -0
  18. package/dist/node20/src/routes/accounts.jsc +0 -0
  19. package/dist/node20/src/routes/campaigns.js +2 -0
  20. package/dist/node20/src/routes/campaigns.jsc +0 -0
  21. package/dist/node20/src/routes/inbound.js +2 -0
  22. package/dist/node20/src/routes/inbound.jsc +0 -0
  23. package/dist/node20/src/server.js +2 -0
  24. package/dist/node20/src/server.jsc +0 -0
  25. package/dist/node20/src/utils/csvParser.js +2 -0
  26. package/dist/node20/src/utils/csvParser.jsc +0 -0
  27. package/dist/node20/src/utils/templateEngine.js +2 -0
  28. package/dist/node20/src/utils/templateEngine.jsc +0 -0
  29. package/dist/node22/cli/index.js +2 -0
  30. package/dist/node22/cli/index.jsc +0 -0
  31. package/dist/node22/cli/lib.js +2 -0
  32. package/dist/node22/cli/lib.jsc +0 -0
  33. package/dist/node22/mcp/index.js +2 -0
  34. package/dist/node22/mcp/index.jsc +0 -0
  35. package/dist/node22/src/accountManager.js +2 -0
  36. package/dist/node22/src/accountManager.jsc +0 -0
  37. package/dist/node22/src/campaignRunner.js +2 -0
  38. package/dist/node22/src/campaignRunner.jsc +0 -0
  39. package/dist/node22/src/db.js +2 -0
  40. package/dist/node22/src/db.jsc +0 -0
  41. package/dist/node22/src/paths.js +2 -0
  42. package/dist/node22/src/paths.jsc +0 -0
  43. package/dist/node22/src/routes/accounts.js +2 -0
  44. package/dist/node22/src/routes/accounts.jsc +0 -0
  45. package/dist/node22/src/routes/campaigns.js +2 -0
  46. package/dist/node22/src/routes/campaigns.jsc +0 -0
  47. package/dist/node22/src/routes/inbound.js +2 -0
  48. package/dist/node22/src/routes/inbound.jsc +0 -0
  49. package/dist/node22/src/server.js +2 -0
  50. package/dist/node22/src/server.jsc +0 -0
  51. package/dist/node22/src/utils/csvParser.js +2 -0
  52. package/dist/node22/src/utils/csvParser.jsc +0 -0
  53. package/dist/node22/src/utils/templateEngine.js +2 -0
  54. package/dist/node22/src/utils/templateEngine.jsc +0 -0
  55. package/dist/node24/cli/index.js +2 -0
  56. package/dist/node24/cli/index.jsc +0 -0
  57. package/dist/node24/cli/lib.js +2 -0
  58. package/dist/node24/cli/lib.jsc +0 -0
  59. package/dist/node24/mcp/index.js +2 -0
  60. package/dist/node24/mcp/index.jsc +0 -0
  61. package/dist/node24/src/accountManager.js +2 -0
  62. package/dist/node24/src/accountManager.jsc +0 -0
  63. package/dist/node24/src/campaignRunner.js +2 -0
  64. package/dist/node24/src/campaignRunner.jsc +0 -0
  65. package/dist/node24/src/db.js +2 -0
  66. package/dist/node24/src/db.jsc +0 -0
  67. package/dist/node24/src/paths.js +2 -0
  68. package/dist/node24/src/paths.jsc +0 -0
  69. package/dist/node24/src/routes/accounts.js +2 -0
  70. package/dist/node24/src/routes/accounts.jsc +0 -0
  71. package/dist/node24/src/routes/campaigns.js +2 -0
  72. package/dist/node24/src/routes/campaigns.jsc +0 -0
  73. package/dist/node24/src/routes/inbound.js +2 -0
  74. package/dist/node24/src/routes/inbound.jsc +0 -0
  75. package/dist/node24/src/server.js +2 -0
  76. package/dist/node24/src/server.jsc +0 -0
  77. package/dist/node24/src/utils/csvParser.js +2 -0
  78. package/dist/node24/src/utils/csvParser.jsc +0 -0
  79. package/dist/node24/src/utils/templateEngine.js +2 -0
  80. package/dist/node24/src/utils/templateEngine.jsc +0 -0
  81. package/package.json +44 -0
  82. package/public/index.html +2459 -0
@@ -0,0 +1,2459 @@
1
+ <!DOCTYPE html>
2
+ <html lang="es">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>WA BLAST CONTROL</title>
7
+ <link rel="preconnect" href="https://fonts.googleapis.com">
8
+ <link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@300;400;500;600;700&display=swap" rel="stylesheet">
9
+ <style>
10
+ :root {
11
+ --green: #00ff88;
12
+ --green-dim: rgba(0,255,136,0.6);
13
+ --green-ghost: rgba(0,255,136,0.08);
14
+ --green-border: rgba(0,255,136,0.2);
15
+ --amber: #ffb800;
16
+ --amber-ghost: rgba(255,184,0,0.1);
17
+ --red: #ff3a5c;
18
+ --red-ghost: rgba(255,58,92,0.1);
19
+ --bg: #080b0f;
20
+ --surface: #0d1117;
21
+ --surface2: #111821;
22
+ --text: #c8d6e5;
23
+ --text-dim: rgba(200,214,229,0.5);
24
+ --font: 'JetBrains Mono', monospace;
25
+ --glow: 0 0 12px rgba(0,255,136,0.4);
26
+ --glow-sm: 0 0 6px rgba(0,255,136,0.3);
27
+ }
28
+
29
+ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
30
+
31
+ body {
32
+ font-family: var(--font);
33
+ background: var(--bg);
34
+ color: var(--text);
35
+ min-height: 100vh;
36
+ overflow-x: hidden;
37
+ background-image:
38
+ radial-gradient(ellipse at 20% 50%, rgba(0,255,136,0.03) 0%, transparent 60%),
39
+ radial-gradient(ellipse at 80% 20%, rgba(0,200,255,0.02) 0%, transparent 50%);
40
+ }
41
+
42
+ /* ── HEADER ── */
43
+ header {
44
+ display: flex;
45
+ align-items: center;
46
+ justify-content: space-between;
47
+ padding: 0 24px;
48
+ height: 52px;
49
+ border-bottom: 1px solid var(--green-border);
50
+ background: var(--surface);
51
+ position: sticky;
52
+ top: 0;
53
+ z-index: 100;
54
+ overflow: hidden;
55
+ }
56
+ header::before {
57
+ content: '';
58
+ position: absolute;
59
+ top: 0; left: 0; right: 0; bottom: 0;
60
+ background: repeating-linear-gradient(
61
+ 0deg,
62
+ transparent,
63
+ transparent 3px,
64
+ rgba(0,255,136,0.015) 3px,
65
+ rgba(0,255,136,0.015) 4px
66
+ );
67
+ pointer-events: none;
68
+ }
69
+ .logo {
70
+ font-size: 14px;
71
+ font-weight: 700;
72
+ color: var(--green);
73
+ letter-spacing: 3px;
74
+ text-shadow: var(--glow);
75
+ }
76
+ .logo span { color: var(--text-dim); font-weight: 300; }
77
+ .header-center { display: flex; align-items: center; gap: 20px; }
78
+ .server-status {
79
+ display: flex;
80
+ align-items: center;
81
+ gap: 7px;
82
+ font-size: 11px;
83
+ color: var(--text-dim);
84
+ letter-spacing: 1px;
85
+ }
86
+ .status-dot {
87
+ width: 7px; height: 7px;
88
+ border-radius: 50%;
89
+ background: var(--green);
90
+ box-shadow: var(--glow-sm);
91
+ animation: pulse 2s ease-in-out infinite;
92
+ }
93
+ .status-dot.offline { background: var(--red); box-shadow: 0 0 6px rgba(255,58,92,0.4); animation: none; }
94
+ @keyframes pulse {
95
+ 0%, 100% { opacity: 1; }
96
+ 50% { opacity: 0.4; }
97
+ }
98
+ .clock {
99
+ font-size: 12px;
100
+ color: var(--green-dim);
101
+ letter-spacing: 2px;
102
+ font-weight: 500;
103
+ }
104
+
105
+ /* ── MAIN GRID ── */
106
+ main {
107
+ display: grid;
108
+ grid-template-columns: 380px 1fr;
109
+ gap: 0;
110
+ height: calc(100vh - 52px);
111
+ overflow: hidden;
112
+ }
113
+
114
+ /* ── PANELS ── */
115
+ .panel {
116
+ border-right: 1px solid var(--green-border);
117
+ display: flex;
118
+ flex-direction: column;
119
+ overflow: hidden;
120
+ }
121
+ .panel:last-child { border-right: none; }
122
+ .panel-header {
123
+ display: flex;
124
+ align-items: center;
125
+ justify-content: space-between;
126
+ padding: 14px 20px;
127
+ border-bottom: 1px solid var(--green-border);
128
+ background: var(--surface);
129
+ flex-shrink: 0;
130
+ }
131
+ .panel-title {
132
+ font-size: 11px;
133
+ font-weight: 600;
134
+ color: var(--green);
135
+ letter-spacing: 2px;
136
+ }
137
+ .panel-count {
138
+ font-size: 10px;
139
+ color: var(--text-dim);
140
+ letter-spacing: 1px;
141
+ }
142
+ .panel-body {
143
+ flex: 1;
144
+ overflow-y: auto;
145
+ padding: 16px;
146
+ display: flex;
147
+ flex-direction: column;
148
+ gap: 12px;
149
+ }
150
+ .panel-body::-webkit-scrollbar { width: 4px; }
151
+ .panel-body::-webkit-scrollbar-track { background: transparent; }
152
+ .panel-body::-webkit-scrollbar-thumb { background: var(--green-border); border-radius: 2px; }
153
+
154
+ /* ── BUTTONS ── */
155
+ .btn {
156
+ font-family: var(--font);
157
+ font-size: 10px;
158
+ font-weight: 600;
159
+ letter-spacing: 1.5px;
160
+ padding: 6px 12px;
161
+ border: 1px solid currentColor;
162
+ background: transparent;
163
+ cursor: pointer;
164
+ transition: all 0.15s;
165
+ text-transform: uppercase;
166
+ }
167
+ .btn-green {
168
+ color: var(--green);
169
+ border-color: var(--green-border);
170
+ }
171
+ .btn-green:hover {
172
+ background: var(--green-ghost);
173
+ border-color: var(--green);
174
+ box-shadow: var(--glow-sm);
175
+ }
176
+ .btn-amber {
177
+ color: var(--amber);
178
+ border-color: rgba(255,184,0,0.3);
179
+ }
180
+ .btn-amber:hover {
181
+ background: var(--amber-ghost);
182
+ border-color: var(--amber);
183
+ }
184
+ .btn-red {
185
+ color: var(--red);
186
+ border-color: rgba(255,58,92,0.3);
187
+ }
188
+ .btn-red:hover {
189
+ background: var(--red-ghost);
190
+ border-color: var(--red);
191
+ }
192
+ .btn-sm { font-size: 9px; padding: 4px 8px; }
193
+ .btn-full { width: 100%; justify-content: center; display: flex; align-items: center; gap: 6px; }
194
+ .btn:disabled { opacity: 0.3; cursor: not-allowed; }
195
+
196
+ /* ── ADD ACCOUNT FORM ── */
197
+ .add-form {
198
+ display: none;
199
+ gap: 8px;
200
+ margin-top: 4px;
201
+ animation: fadeIn 0.15s ease;
202
+ }
203
+ .add-form.visible { display: flex; }
204
+ @keyframes fadeIn { from { opacity: 0; transform: translateY(-4px); } to { opacity: 1; transform: translateY(0); } }
205
+ .input {
206
+ font-family: var(--font);
207
+ font-size: 12px;
208
+ background: var(--surface2);
209
+ border: 1px solid var(--green-border);
210
+ color: var(--text);
211
+ padding: 7px 10px;
212
+ outline: none;
213
+ flex: 1;
214
+ transition: border-color 0.15s;
215
+ }
216
+ .input:focus {
217
+ border-color: var(--green);
218
+ box-shadow: 0 0 0 1px rgba(0,255,136,0.1);
219
+ }
220
+ .input::placeholder { color: var(--text-dim); font-size: 11px; }
221
+ textarea.input { resize: vertical; min-height: 80px; }
222
+
223
+ /* ── ACCOUNT CARD ── */
224
+ .account-card {
225
+ background: var(--surface);
226
+ border: 1px solid var(--green-border);
227
+ padding: 14px 16px;
228
+ transition: border-color 0.2s;
229
+ animation: fadeIn 0.2s ease;
230
+ }
231
+ .account-card:hover { border-color: rgba(0,255,136,0.4); }
232
+ .account-card.ready { border-left: 2px solid var(--green); }
233
+ .account-card.pending { border-left: 2px solid var(--amber); }
234
+ .account-card.disconnected { border-left: 2px solid var(--red); }
235
+ .card-row {
236
+ display: flex;
237
+ align-items: center;
238
+ justify-content: space-between;
239
+ margin-bottom: 8px;
240
+ }
241
+ .card-row:last-child { margin-bottom: 0; }
242
+ .account-id {
243
+ font-size: 13px;
244
+ font-weight: 600;
245
+ color: var(--text);
246
+ letter-spacing: 0.5px;
247
+ }
248
+ .account-phone {
249
+ font-size: 10px;
250
+ color: var(--text-dim);
251
+ margin-top: 2px;
252
+ }
253
+ .card-actions { display: flex; gap: 6px; align-items: center; }
254
+
255
+ /* ── STATUS BADGE ── */
256
+ .badge {
257
+ font-size: 9px;
258
+ font-weight: 600;
259
+ letter-spacing: 1.5px;
260
+ padding: 3px 7px;
261
+ border: 1px solid currentColor;
262
+ display: inline-flex;
263
+ align-items: center;
264
+ gap: 5px;
265
+ }
266
+ .badge-green { color: var(--green); }
267
+ .badge-amber { color: var(--amber); }
268
+ .badge-red { color: var(--red); }
269
+ .badge-dim { color: var(--text-dim); border-color: rgba(200,214,229,0.2); }
270
+ .badge-running { color: var(--green); animation: blink 1.2s step-end infinite; }
271
+ @keyframes blink { 50% { opacity: 0.4; } }
272
+ .badge-dot { width: 5px; height: 5px; border-radius: 50%; background: currentColor; }
273
+
274
+ /* ── CAMPAIGN CARD ── */
275
+ .campaign-card {
276
+ background: var(--surface);
277
+ border: 1px solid var(--green-border);
278
+ padding: 16px 18px;
279
+ transition: border-color 0.2s;
280
+ animation: fadeIn 0.2s ease;
281
+ }
282
+ .campaign-card:hover { border-color: rgba(0,255,136,0.35); }
283
+ .campaign-card.running { border-top: 2px solid var(--green); }
284
+ .campaign-card.paused { border-top: 2px solid var(--amber); }
285
+ .campaign-card.done { border-top: 2px solid rgba(0,255,136,0.3); opacity: 0.75; }
286
+ .campaign-card.cancelled { border-top: 2px solid rgba(255,58,92,0.4); opacity: 0.6; }
287
+ .campaign-name {
288
+ font-size: 13px;
289
+ font-weight: 600;
290
+ color: var(--text);
291
+ margin-bottom: 3px;
292
+ }
293
+ .campaign-meta {
294
+ font-size: 10px;
295
+ color: var(--text-dim);
296
+ margin-bottom: 12px;
297
+ }
298
+ .campaign-meta span { color: var(--green-dim); }
299
+
300
+ /* ── PROGRESS BAR ── */
301
+ .progress-wrap { margin-bottom: 10px; }
302
+ .progress-label {
303
+ display: flex;
304
+ justify-content: space-between;
305
+ font-size: 10px;
306
+ color: var(--text-dim);
307
+ margin-bottom: 5px;
308
+ }
309
+ .progress-pct { color: var(--green); font-weight: 600; }
310
+ .progress-bar {
311
+ height: 4px;
312
+ background: rgba(0,255,136,0.1);
313
+ overflow: hidden;
314
+ position: relative;
315
+ }
316
+ .progress-fill {
317
+ height: 100%;
318
+ background: var(--green);
319
+ box-shadow: 0 0 8px rgba(0,255,136,0.5);
320
+ transition: width 0.5s ease;
321
+ }
322
+ .progress-fill.running::after {
323
+ content: '';
324
+ position: absolute;
325
+ top: 0; bottom: 0;
326
+ width: 40px;
327
+ background: linear-gradient(to right, transparent, rgba(0,255,136,0.4), transparent);
328
+ animation: shimmer 1.5s linear infinite;
329
+ }
330
+ @keyframes shimmer {
331
+ from { left: -40px; }
332
+ to { left: 100%; }
333
+ }
334
+
335
+ /* ── STATS ROW ── */
336
+ .stats-row {
337
+ display: flex;
338
+ gap: 16px;
339
+ font-size: 10px;
340
+ margin-bottom: 12px;
341
+ padding: 8px 10px;
342
+ background: rgba(0,255,136,0.03);
343
+ border: 1px solid rgba(0,255,136,0.07);
344
+ }
345
+ .stat-item { display: flex; align-items: center; gap: 5px; }
346
+ .stat-val { font-weight: 700; }
347
+ .stat-sent { color: var(--green); }
348
+ .stat-failed { color: var(--red); }
349
+ .stat-pending { color: var(--text-dim); }
350
+
351
+ /* ── TEMPLATE PREVIEW ── */
352
+ .template-preview {
353
+ font-size: 10px;
354
+ color: var(--text-dim);
355
+ background: var(--surface2);
356
+ border: 1px solid rgba(0,255,136,0.07);
357
+ padding: 7px 10px;
358
+ margin-bottom: 12px;
359
+ line-height: 1.5;
360
+ white-space: pre-wrap;
361
+ word-break: break-word;
362
+ max-height: 60px;
363
+ overflow: hidden;
364
+ position: relative;
365
+ }
366
+ .template-preview::after {
367
+ content: '';
368
+ position: absolute;
369
+ bottom: 0; left: 0; right: 0;
370
+ height: 20px;
371
+ background: linear-gradient(to bottom, transparent, var(--surface));
372
+ }
373
+
374
+ /* ── EMPTY STATE ── */
375
+ .empty-state {
376
+ text-align: center;
377
+ padding: 40px 20px;
378
+ color: var(--text-dim);
379
+ font-size: 11px;
380
+ letter-spacing: 0.5px;
381
+ line-height: 2;
382
+ border: 1px dashed rgba(0,255,136,0.1);
383
+ }
384
+ .empty-state .empty-icon { font-size: 28px; margin-bottom: 12px; opacity: 0.3; }
385
+
386
+ /* ── MODAL ── */
387
+ .modal-overlay {
388
+ display: none;
389
+ position: fixed;
390
+ inset: 0;
391
+ background: rgba(0,0,0,0.85);
392
+ z-index: 1000;
393
+ align-items: center;
394
+ justify-content: center;
395
+ animation: fadeIn 0.15s ease;
396
+ }
397
+ .modal-overlay.visible { display: flex; }
398
+ .modal {
399
+ background: var(--surface);
400
+ border: 1px solid var(--green-border);
401
+ padding: 28px;
402
+ width: 480px;
403
+ max-width: 95vw;
404
+ max-height: 90vh;
405
+ overflow-y: auto;
406
+ position: relative;
407
+ box-shadow: 0 0 60px rgba(0,255,136,0.1);
408
+ }
409
+ .modal-title {
410
+ font-size: 12px;
411
+ font-weight: 700;
412
+ color: var(--green);
413
+ letter-spacing: 2px;
414
+ margin-bottom: 24px;
415
+ padding-bottom: 12px;
416
+ border-bottom: 1px solid var(--green-border);
417
+ }
418
+ .modal-close {
419
+ position: absolute;
420
+ top: 16px; right: 16px;
421
+ background: none;
422
+ border: none;
423
+ color: var(--text-dim);
424
+ font-size: 16px;
425
+ cursor: pointer;
426
+ font-family: var(--font);
427
+ padding: 4px 8px;
428
+ transition: color 0.15s;
429
+ }
430
+ .modal-close:hover { color: var(--red); }
431
+ .form-group { margin-bottom: 16px; }
432
+ .form-label {
433
+ display: block;
434
+ font-size: 10px;
435
+ color: var(--green-dim);
436
+ letter-spacing: 1.5px;
437
+ margin-bottom: 6px;
438
+ font-weight: 600;
439
+ }
440
+ .form-hint {
441
+ font-size: 10px;
442
+ color: var(--text-dim);
443
+ margin-top: 4px;
444
+ }
445
+ select.input {
446
+ width: 100%;
447
+ cursor: pointer;
448
+ }
449
+ select.input option { background: var(--surface2); }
450
+
451
+ /* ── QR MODAL ── */
452
+ .qr-container {
453
+ text-align: center;
454
+ padding: 20px 0;
455
+ }
456
+ .qr-img {
457
+ max-width: 240px;
458
+ border: 8px solid white;
459
+ display: block;
460
+ margin: 0 auto 16px;
461
+ }
462
+ .qr-status-text {
463
+ font-size: 11px;
464
+ color: var(--text-dim);
465
+ letter-spacing: 1px;
466
+ line-height: 1.8;
467
+ }
468
+ .qr-scanning {
469
+ color: var(--amber);
470
+ font-size: 11px;
471
+ letter-spacing: 1px;
472
+ animation: blink 1s ease-in-out infinite;
473
+ }
474
+
475
+ /* ── TOAST ── */
476
+ .toast-container {
477
+ position: fixed;
478
+ bottom: 20px;
479
+ right: 20px;
480
+ z-index: 2000;
481
+ display: flex;
482
+ flex-direction: column;
483
+ gap: 8px;
484
+ pointer-events: none;
485
+ }
486
+ .toast {
487
+ background: var(--surface2);
488
+ border: 1px solid var(--green-border);
489
+ padding: 10px 16px;
490
+ font-size: 11px;
491
+ letter-spacing: 0.5px;
492
+ min-width: 240px;
493
+ animation: slideIn 0.2s ease;
494
+ pointer-events: auto;
495
+ }
496
+ .toast.success { border-left: 3px solid var(--green); color: var(--green); }
497
+ .toast.error { border-left: 3px solid var(--red); color: var(--red); border-color: rgba(255,58,92,0.3); }
498
+ .toast.info { border-left: 3px solid var(--amber); color: var(--amber); border-color: rgba(255,184,0,0.3); }
499
+ .toast.out { animation: slideOut 0.2s ease forwards; }
500
+ @keyframes slideIn { from { opacity: 0; transform: translateX(20px); } to { opacity: 1; transform: translateX(0); } }
501
+ @keyframes slideOut { from { opacity: 1; transform: translateX(0); } to { opacity: 0; transform: translateX(20px); } }
502
+
503
+ /* ── FILE DROP ── */
504
+ .file-drop {
505
+ border: 1px dashed var(--green-border);
506
+ padding: 20px;
507
+ text-align: center;
508
+ cursor: pointer;
509
+ transition: all 0.15s;
510
+ font-size: 11px;
511
+ color: var(--text-dim);
512
+ letter-spacing: 0.5px;
513
+ }
514
+ .file-drop:hover, .file-drop.drag-over {
515
+ border-color: var(--green);
516
+ background: var(--green-ghost);
517
+ color: var(--green);
518
+ }
519
+ .file-drop .drop-icon { font-size: 20px; margin-bottom: 8px; }
520
+
521
+ /* ── ACCOUNT CHECKBOXES (modal) ── */
522
+ .accounts-checkbox-list {
523
+ display: flex;
524
+ flex-direction: column;
525
+ gap: 4px;
526
+ max-height: 180px;
527
+ overflow-y: auto;
528
+ border: 1px solid var(--green-border);
529
+ padding: 6px;
530
+ background: var(--surface2);
531
+ }
532
+ .accounts-checkbox-list::-webkit-scrollbar { width: 4px; }
533
+ .accounts-checkbox-list::-webkit-scrollbar-thumb { background: var(--green-border); border-radius: 2px; }
534
+ .account-checkbox-item {
535
+ display: flex;
536
+ align-items: center;
537
+ gap: 10px;
538
+ padding: 7px 10px;
539
+ cursor: pointer;
540
+ border: 1px solid transparent;
541
+ transition: all 0.15s;
542
+ }
543
+ .account-checkbox-item:hover {
544
+ border-color: var(--green-border);
545
+ background: var(--green-ghost);
546
+ }
547
+ .account-checkbox-item input[type="checkbox"] {
548
+ accent-color: var(--green);
549
+ width: 14px;
550
+ height: 14px;
551
+ cursor: pointer;
552
+ flex-shrink: 0;
553
+ }
554
+ .account-checkbox-info { flex: 1; }
555
+ .account-checkbox-id {
556
+ font-size: 12px;
557
+ font-weight: 600;
558
+ color: var(--text);
559
+ display: block;
560
+ }
561
+ .account-checkbox-phone { font-size: 10px; color: var(--text-dim); }
562
+ .no-accounts-msg {
563
+ font-size: 10px;
564
+ color: var(--text-dim);
565
+ padding: 14px;
566
+ text-align: center;
567
+ letter-spacing: 0.5px;
568
+ }
569
+
570
+ /* ── ACCOUNT PILLS (campaign cards) ── */
571
+ .account-pill {
572
+ display: inline-block;
573
+ padding: 2px 7px;
574
+ font-size: 9px;
575
+ font-weight: 600;
576
+ letter-spacing: 0.5px;
577
+ color: var(--green);
578
+ border: 1px solid var(--green-border);
579
+ background: var(--green-ghost);
580
+ margin-right: 4px;
581
+ vertical-align: middle;
582
+ }
583
+
584
+ /* ── DIVIDER ── */
585
+ .divider {
586
+ height: 1px;
587
+ background: var(--green-border);
588
+ margin: 16px 0;
589
+ }
590
+
591
+ /* ── LOADING ── */
592
+ .loading-dots::after {
593
+ content: '';
594
+ animation: dots 1.2s steps(4, end) infinite;
595
+ }
596
+ @keyframes dots {
597
+ 0% { content: ''; }
598
+ 25% { content: '.'; }
599
+ 50% { content: '..'; }
600
+ 75% { content: '...'; }
601
+ 100% { content: ''; }
602
+ }
603
+
604
+ /* ── TRACKING MODAL ── */
605
+ .tracking-modal { width: 580px; }
606
+
607
+ .track-tabs {
608
+ display: flex;
609
+ gap: 0;
610
+ margin-bottom: 20px;
611
+ border-bottom: 1px solid var(--green-border);
612
+ }
613
+ .track-tab {
614
+ font-family: var(--font);
615
+ font-size: 10px;
616
+ font-weight: 600;
617
+ letter-spacing: 1.5px;
618
+ padding: 8px 16px;
619
+ background: none;
620
+ border: none;
621
+ border-bottom: 2px solid transparent;
622
+ color: var(--text-dim);
623
+ cursor: pointer;
624
+ transition: all 0.15s;
625
+ margin-bottom: -1px;
626
+ }
627
+ .track-tab:hover { color: var(--text); }
628
+ .track-tab.active {
629
+ color: var(--green);
630
+ border-bottom-color: var(--green);
631
+ text-shadow: var(--glow-sm);
632
+ }
633
+
634
+ .track-pane { display: none; }
635
+ .track-pane.active { display: block; }
636
+
637
+ /* Funnel ACK */
638
+ .funnel-row {
639
+ display: grid;
640
+ grid-template-columns: 90px 1fr 56px 44px;
641
+ align-items: center;
642
+ gap: 10px;
643
+ padding: 6px 0;
644
+ border-bottom: 1px solid rgba(0,255,136,0.05);
645
+ }
646
+ .funnel-row:last-child { border-bottom: none; }
647
+ .funnel-label {
648
+ font-size: 10px;
649
+ color: var(--text-dim);
650
+ letter-spacing: 1px;
651
+ font-weight: 500;
652
+ text-align: right;
653
+ }
654
+ .funnel-bar-wrap {
655
+ height: 14px;
656
+ background: rgba(0,255,136,0.06);
657
+ position: relative;
658
+ overflow: hidden;
659
+ }
660
+ .funnel-bar {
661
+ height: 100%;
662
+ transition: width 0.7s cubic-bezier(.4,0,.2,1);
663
+ position: relative;
664
+ }
665
+ .funnel-bar::after {
666
+ content: '';
667
+ position: absolute;
668
+ inset: 0;
669
+ background: repeating-linear-gradient(
670
+ 90deg,
671
+ transparent,
672
+ transparent 4px,
673
+ rgba(0,0,0,0.15) 4px,
674
+ rgba(0,0,0,0.15) 5px
675
+ );
676
+ }
677
+ .funnel-bar.bar-total { background: rgba(200,214,229,0.25); }
678
+ .funnel-bar.bar-sent { background: rgba(0,255,136,0.4); }
679
+ .funnel-bar.bar-server { background: rgba(0,200,255,0.5); }
680
+ .funnel-bar.bar-delivered{ background: rgba(0,255,136,0.6); box-shadow: 0 0 8px rgba(0,255,136,0.2) inset; }
681
+ .funnel-bar.bar-read { background: var(--green); box-shadow: 0 0 10px rgba(0,255,136,0.4) inset; }
682
+ .funnel-bar.bar-played { background: #00e5ff; box-shadow: 0 0 10px rgba(0,229,255,0.4) inset; }
683
+ .funnel-bar.bar-error { background: var(--red); }
684
+ .funnel-bar.bar-replies { background: var(--amber); }
685
+ .funnel-count {
686
+ font-size: 12px;
687
+ font-weight: 700;
688
+ color: var(--text);
689
+ text-align: right;
690
+ font-variant-numeric: tabular-nums;
691
+ }
692
+ .funnel-pct {
693
+ font-size: 10px;
694
+ color: var(--text-dim);
695
+ text-align: right;
696
+ font-variant-numeric: tabular-nums;
697
+ }
698
+
699
+ /* KPI row */
700
+ .kpi-row {
701
+ display: grid;
702
+ grid-template-columns: repeat(3, 1fr);
703
+ gap: 10px;
704
+ margin-bottom: 20px;
705
+ }
706
+ .kpi-card {
707
+ background: rgba(0,255,136,0.04);
708
+ border: 1px solid rgba(0,255,136,0.1);
709
+ padding: 12px 14px;
710
+ text-align: center;
711
+ }
712
+ .kpi-val {
713
+ font-size: 24px;
714
+ font-weight: 700;
715
+ color: var(--green);
716
+ text-shadow: var(--glow-sm);
717
+ line-height: 1;
718
+ margin-bottom: 4px;
719
+ }
720
+ .kpi-val.kpi-amber { color: var(--amber); text-shadow: 0 0 6px rgba(255,184,0,0.3); }
721
+ .kpi-val.kpi-cyan { color: #00e5ff; text-shadow: 0 0 6px rgba(0,229,255,0.3); }
722
+ .kpi-label {
723
+ font-size: 9px;
724
+ color: var(--text-dim);
725
+ letter-spacing: 1.5px;
726
+ text-transform: uppercase;
727
+ }
728
+
729
+ /* Replies list */
730
+ .replies-list {
731
+ display: flex;
732
+ flex-direction: column;
733
+ gap: 8px;
734
+ max-height: 340px;
735
+ overflow-y: auto;
736
+ }
737
+ .replies-list::-webkit-scrollbar { width: 4px; }
738
+ .replies-list::-webkit-scrollbar-thumb { background: var(--green-border); border-radius: 2px; }
739
+
740
+ .reply-item {
741
+ background: var(--surface2);
742
+ border: 1px solid var(--green-border);
743
+ border-left: 3px solid var(--amber);
744
+ padding: 10px 14px;
745
+ animation: fadeIn 0.2s ease;
746
+ }
747
+ .reply-header {
748
+ display: flex;
749
+ justify-content: space-between;
750
+ align-items: center;
751
+ margin-bottom: 6px;
752
+ }
753
+ .reply-phone {
754
+ font-size: 11px;
755
+ font-weight: 600;
756
+ color: var(--amber);
757
+ letter-spacing: 0.5px;
758
+ }
759
+ .reply-time {
760
+ font-size: 9px;
761
+ color: var(--text-dim);
762
+ letter-spacing: 0.5px;
763
+ }
764
+ .reply-body {
765
+ font-size: 12px;
766
+ color: var(--text);
767
+ line-height: 1.5;
768
+ word-break: break-word;
769
+ }
770
+
771
+ .track-refresh {
772
+ display: flex;
773
+ align-items: center;
774
+ gap: 8px;
775
+ margin-bottom: 16px;
776
+ font-size: 10px;
777
+ color: var(--text-dim);
778
+ }
779
+ .track-refresh-dot {
780
+ width: 6px; height: 6px;
781
+ border-radius: 50%;
782
+ background: var(--green);
783
+ animation: pulse 2s ease-in-out infinite;
784
+ }
785
+
786
+ /* ── INBOUND ── */
787
+ .inbound-card {
788
+ background: var(--surface2);
789
+ border: 1px solid var(--green-border);
790
+ border-left: 3px solid #00e5ff;
791
+ padding: 14px 16px;
792
+ margin-top: 10px;
793
+ animation: fadeIn 0.2s ease;
794
+ }
795
+ .inbound-card.status-exhausted { border-left-color: var(--red); opacity: 0.75; }
796
+ .inbound-card.status-paused { border-left-color: var(--amber); }
797
+ .inbound-header {
798
+ display: flex;
799
+ align-items: flex-start;
800
+ justify-content: space-between;
801
+ gap: 12px;
802
+ margin-bottom: 10px;
803
+ }
804
+ .inbound-meta { flex: 1; min-width: 0; }
805
+ .inbound-name {
806
+ font-size: 13px;
807
+ font-weight: 600;
808
+ color: var(--text);
809
+ white-space: nowrap;
810
+ overflow: hidden;
811
+ text-overflow: ellipsis;
812
+ margin-bottom: 3px;
813
+ }
814
+ .inbound-account {
815
+ font-size: 10px;
816
+ color: var(--text-dim);
817
+ letter-spacing: 0.5px;
818
+ }
819
+ .inbound-progress {
820
+ margin-bottom: 10px;
821
+ }
822
+ .inbound-progress-bar-wrap {
823
+ height: 6px;
824
+ background: rgba(255,255,255,0.06);
825
+ border-radius: 3px;
826
+ overflow: hidden;
827
+ margin-bottom: 4px;
828
+ }
829
+ .inbound-progress-bar {
830
+ height: 100%;
831
+ background: #00e5ff;
832
+ border-radius: 3px;
833
+ transition: width 0.4s ease;
834
+ }
835
+ .inbound-progress-bar.exhausted { background: var(--red); }
836
+ .inbound-progress-bar.paused { background: var(--amber); }
837
+ .inbound-progress-label {
838
+ font-size: 10px;
839
+ color: var(--text-dim);
840
+ letter-spacing: 0.5px;
841
+ }
842
+ .inbound-template {
843
+ font-size: 11px;
844
+ color: var(--text-dim);
845
+ line-height: 1.5;
846
+ border-left: 2px solid rgba(0,229,255,0.2);
847
+ padding-left: 8px;
848
+ margin-bottom: 10px;
849
+ word-break: break-word;
850
+ max-height: 60px;
851
+ overflow: hidden;
852
+ text-overflow: ellipsis;
853
+ }
854
+ .inbound-actions { display: flex; gap: 6px; flex-wrap: wrap; }
855
+
856
+ /* ── CUPONES ── */
857
+ .coupon-bar {
858
+ display: flex;
859
+ align-items: center;
860
+ gap: 8px;
861
+ margin-bottom: 8px;
862
+ font-size: 10px;
863
+ color: var(--text-dim);
864
+ letter-spacing: 0.5px;
865
+ }
866
+ .coupon-pill {
867
+ display: inline-flex;
868
+ align-items: center;
869
+ gap: 4px;
870
+ padding: 2px 8px;
871
+ font-size: 10px;
872
+ font-weight: 600;
873
+ letter-spacing: 0.5px;
874
+ border: 1px solid rgba(0,229,255,0.3);
875
+ color: #00e5ff;
876
+ background: rgba(0,229,255,0.06);
877
+ }
878
+ .coupon-pill.empty { border-color: rgba(255,58,92,0.3); color: var(--red); background: var(--red-ghost); }
879
+ </style>
880
+ </head>
881
+ <body>
882
+
883
+ <!-- HEADER -->
884
+ <header>
885
+ <div class="logo">▓▓ WA BLAST <span>/ CONTROL PANEL</span></div>
886
+ <div class="header-center">
887
+ <div class="server-status">
888
+ <div class="status-dot" id="serverDot"></div>
889
+ <span id="serverLabel">CONECTANDO</span>
890
+ </div>
891
+ </div>
892
+ <div class="clock" id="clock">00:00:00</div>
893
+ </header>
894
+
895
+ <!-- MAIN -->
896
+ <main>
897
+ <!-- PANEL ACCOUNTS -->
898
+ <div class="panel">
899
+ <div class="panel-header">
900
+ <span class="panel-title">ACCOUNTS</span>
901
+ <span class="panel-count" id="accountCount">0 / 3</span>
902
+ </div>
903
+ <div class="panel-body" id="accountsPanel">
904
+ <!-- Botón nuevo account -->
905
+ <div>
906
+ <button class="btn btn-green btn-full" onclick="toggleAddAccountForm()">
907
+ <span>+</span> NUEVA CUENTA
908
+ </button>
909
+ <div class="add-form" id="addAccountForm">
910
+ <input class="input" type="text" id="newAccountId" placeholder="ej. cuenta1" maxlength="30">
911
+ <button class="btn btn-green" onclick="createAccount()">OK</button>
912
+ <button class="btn btn-red btn-sm" onclick="toggleAddAccountForm()">✕</button>
913
+ </div>
914
+ </div>
915
+ <!-- Lista de cuentas -->
916
+ <div id="accountsList"></div>
917
+ </div>
918
+ </div>
919
+
920
+ <!-- PANEL CAMPAIGNS -->
921
+ <div class="panel">
922
+ <div class="panel-header">
923
+ <span class="panel-title">CAMPAIGNS</span>
924
+ <span class="panel-count" id="campaignCount">0 activas</span>
925
+ </div>
926
+ <div class="panel-body">
927
+ <div>
928
+ <button class="btn btn-green btn-full" onclick="openNewCampaignModal()">
929
+ <span>+</span> NUEVA CAMPAÑA
930
+ </button>
931
+ </div>
932
+ <div id="campaignsList"></div>
933
+ </div>
934
+ </div>
935
+
936
+ <!-- PANEL INBOUND -->
937
+ <div class="panel" style="grid-column: 1 / -1;">
938
+ <div class="panel-header">
939
+ <span class="panel-title">INBOUND — AUTO-RESPUESTA</span>
940
+ <span class="panel-count" id="inboundCount">0 activas</span>
941
+ </div>
942
+ <div class="panel-body">
943
+ <div>
944
+ <button class="btn btn-green btn-full" onclick="openNewInboundModal()">
945
+ <span>+</span> NUEVA CAMPAÑA INBOUND
946
+ </button>
947
+ </div>
948
+ <div id="inboundList"></div>
949
+ </div>
950
+ </div>
951
+ </main>
952
+
953
+ <!-- MODAL QR -->
954
+ <div class="modal-overlay" id="qrModal">
955
+ <div class="modal" style="width:360px; text-align:center;">
956
+ <button class="modal-close" onclick="closeQrModal()">✕</button>
957
+ <div class="modal-title" style="text-align:center;">ESCANEAR QR</div>
958
+ <div class="qr-container" id="qrContainer">
959
+ <p class="qr-scanning loading-dots">Generando QR</p>
960
+ </div>
961
+ <div class="qr-status-text" id="qrStatusText">
962
+ Abre WhatsApp → Dispositivos vinculados → Vincular dispositivo
963
+ </div>
964
+ </div>
965
+ </div>
966
+
967
+ <!-- MODAL NUEVA CAMPAÑA -->
968
+ <div class="modal-overlay" id="campaignModal">
969
+ <div class="modal">
970
+ <button class="modal-close" onclick="closeCampaignModal()">✕</button>
971
+ <div class="modal-title">NUEVA CAMPAÑA</div>
972
+
973
+ <div class="form-group">
974
+ <label class="form-label">NOMBRE</label>
975
+ <input class="input" type="text" id="campName" placeholder="Campaña Primavera 2026" style="width:100%">
976
+ </div>
977
+
978
+ <div class="form-group">
979
+ <label class="form-label">CUENTAS WHATSAPP <span style="font-weight:300;letter-spacing:0">(una o más)</span></label>
980
+ <div class="accounts-checkbox-list" id="campAccountsList">
981
+ <!-- poblado por openNewCampaignModal() -->
982
+ </div>
983
+ <p class="form-hint">Solo cuentas READY. Los contactos se distribuyen en round-robin entre todas.</p>
984
+ </div>
985
+
986
+ <div class="form-group">
987
+ <label class="form-label">TEMPLATE DEL MENSAJE</label>
988
+ <textarea class="input" id="campTemplate" placeholder="Hola {{nombre}}, te contactamos de {{empresa}}..." style="width:100%"></textarea>
989
+ <p class="form-hint">Usa {{variable}} para personalizar. Las columnas del CSV serán las variables.</p>
990
+ </div>
991
+
992
+ <div class="form-group">
993
+ <label class="form-label">DELAY ENTRE MENSAJES (ms)</label>
994
+ <input class="input" type="number" id="campDelay" value="3000" min="1000" step="500" style="width:100%">
995
+ <p class="form-hint">Mínimo 3000ms recomendado para evitar baneós</p>
996
+ </div>
997
+
998
+ <div class="form-group">
999
+ <label class="form-label">JITTER / VARIACIÓN DEL DELAY (ms)</label>
1000
+ <input class="input" type="number" id="campJitter" value="2000" min="0" step="500" style="width:100%">
1001
+ <p class="form-hint">Variación aleatoria. Con delay=4000 y jitter=2000 → espera entre 4s y 6s</p>
1002
+ </div>
1003
+
1004
+ <div class="form-group" style="display:flex;align-items:center;gap:12px">
1005
+ <input type="checkbox" id="campTyping" checked style="width:18px;height:18px;cursor:pointer">
1006
+ <div>
1007
+ <label class="form-label" for="campTyping" style="cursor:pointer">SIMULAR ESCRITURA</label>
1008
+ <p class="form-hint" style="margin:0">Muestra "escribiendo..." antes de cada mensaje</p>
1009
+ </div>
1010
+ </div>
1011
+
1012
+ <div class="form-group">
1013
+ <label class="form-label">PAUSA PERIÓDICA — cada N mensajes</label>
1014
+ <input class="input" type="number" id="campBurstSize" value="0" min="0" step="1" style="width:100%">
1015
+ <p class="form-hint">0 = desactivado. Con 15: pausa larga cada 15 mensajes enviados</p>
1016
+ </div>
1017
+
1018
+ <div class="form-group" id="campBurstPauseGroup">
1019
+ <label class="form-label">DURACIÓN DE LA PAUSA PERIÓDICA (ms)</label>
1020
+ <input class="input" type="number" id="campBurstPause" value="60000" min="5000" step="5000" style="width:100%">
1021
+ </div>
1022
+
1023
+ <div class="form-group">
1024
+ <label class="form-label">LÍMITE DIARIO DE CAMPAÑA</label>
1025
+ <input class="input" type="number" id="campDailyCap" value="0" min="0" step="1" style="width:100%">
1026
+ <p class="form-hint">0 = sin límite de campaña (usa el límite de la cuenta)</p>
1027
+ </div>
1028
+
1029
+ <div class="form-group">
1030
+ <label class="form-label">LÍMITE POR HORA</label>
1031
+ <input class="input" type="number" id="campHourlyCap" value="0" min="0" step="1" style="width:100%">
1032
+ <p class="form-hint">0 = desactivado</p>
1033
+ </div>
1034
+
1035
+ <div class="form-group">
1036
+ <label class="form-label">VENTANA DE ENVÍO</label>
1037
+ <div style="display:flex;gap:8px;align-items:center">
1038
+ <input class="input" type="time" id="campWindowStart" style="flex:1">
1039
+ <span style="color:var(--text-dim)">—</span>
1040
+ <input class="input" type="time" id="campWindowEnd" style="flex:1">
1041
+ </div>
1042
+ <p class="form-hint">Vacío = sin restricción horaria</p>
1043
+ </div>
1044
+
1045
+ <div class="form-group">
1046
+ <label class="form-label">DÍAS ACTIVOS</label>
1047
+ <div style="display:flex;gap:10px;flex-wrap:wrap;margin-top:6px" id="campActiveDaysList">
1048
+ <label style="display:flex;align-items:center;gap:4px;cursor:pointer;font-size:12px"><input type="checkbox" value="1" style="cursor:pointer"> Lun</label>
1049
+ <label style="display:flex;align-items:center;gap:4px;cursor:pointer;font-size:12px"><input type="checkbox" value="2" style="cursor:pointer"> Mar</label>
1050
+ <label style="display:flex;align-items:center;gap:4px;cursor:pointer;font-size:12px"><input type="checkbox" value="3" style="cursor:pointer"> Mié</label>
1051
+ <label style="display:flex;align-items:center;gap:4px;cursor:pointer;font-size:12px"><input type="checkbox" value="4" style="cursor:pointer"> Jue</label>
1052
+ <label style="display:flex;align-items:center;gap:4px;cursor:pointer;font-size:12px"><input type="checkbox" value="5" style="cursor:pointer"> Vie</label>
1053
+ <label style="display:flex;align-items:center;gap:4px;cursor:pointer;font-size:12px"><input type="checkbox" value="6" style="cursor:pointer"> Sáb</label>
1054
+ <label style="display:flex;align-items:center;gap:4px;cursor:pointer;font-size:12px"><input type="checkbox" value="7" style="cursor:pointer"> Dom</label>
1055
+ </div>
1056
+ <p class="form-hint">Sin selección = todos los días</p>
1057
+ </div>
1058
+
1059
+ <button class="btn btn-green btn-full" onclick="createCampaign()" style="margin-top:8px;">
1060
+ CREAR CAMPAÑA
1061
+ </button>
1062
+ </div>
1063
+ </div>
1064
+
1065
+ <!-- MODAL EDITAR CAMPAÑA -->
1066
+ <div class="modal-overlay" id="editCampaignModal">
1067
+ <div class="modal">
1068
+ <button class="modal-close" onclick="closeEditCampaignModal()">✕</button>
1069
+ <div class="modal-title">EDITAR CAMPAÑA</div>
1070
+
1071
+ <div class="form-group">
1072
+ <label class="form-label">NOMBRE</label>
1073
+ <input class="input" type="text" id="editCampName" style="width:100%">
1074
+ </div>
1075
+
1076
+ <div class="form-group">
1077
+ <label class="form-label">TEMPLATE DEL MENSAJE</label>
1078
+ <textarea class="input" id="editCampTemplate" style="width:100%"></textarea>
1079
+ <p class="form-hint">Usa {{variable}} para personalizar.</p>
1080
+ </div>
1081
+
1082
+ <div class="form-group">
1083
+ <label class="form-label">DELAY ENTRE MENSAJES (ms)</label>
1084
+ <input class="input" type="number" id="editCampDelay" min="1000" step="500" style="width:100%">
1085
+ <p class="form-hint">Mínimo 1000ms</p>
1086
+ </div>
1087
+
1088
+ <div class="form-group">
1089
+ <label class="form-label">JITTER / VARIACIÓN DEL DELAY (ms)</label>
1090
+ <input class="input" type="number" id="editCampJitter" value="2000" min="0" step="500" style="width:100%">
1091
+ <p class="form-hint">Variación aleatoria del delay</p>
1092
+ </div>
1093
+
1094
+ <div class="form-group" style="display:flex;align-items:center;gap:12px">
1095
+ <input type="checkbox" id="editCampTyping" style="width:18px;height:18px;cursor:pointer">
1096
+ <div>
1097
+ <label class="form-label" for="editCampTyping" style="cursor:pointer">SIMULAR ESCRITURA</label>
1098
+ <p class="form-hint" style="margin:0">Muestra "escribiendo..." antes de cada mensaje</p>
1099
+ </div>
1100
+ </div>
1101
+
1102
+ <div class="form-group">
1103
+ <label class="form-label">PAUSA PERIÓDICA — cada N mensajes</label>
1104
+ <input class="input" type="number" id="editCampBurstSize" value="0" min="0" step="1" style="width:100%">
1105
+ <p class="form-hint">0 = desactivado</p>
1106
+ </div>
1107
+
1108
+ <div class="form-group">
1109
+ <label class="form-label">DURACIÓN DE LA PAUSA PERIÓDICA (ms)</label>
1110
+ <input class="input" type="number" id="editCampBurstPause" value="60000" min="5000" step="5000" style="width:100%">
1111
+ </div>
1112
+
1113
+ <div class="form-group">
1114
+ <label class="form-label">LÍMITE DIARIO DE CAMPAÑA</label>
1115
+ <input class="input" type="number" id="editCampDailyCap" value="0" min="0" step="1" style="width:100%">
1116
+ <p class="form-hint">0 = sin límite de campaña</p>
1117
+ </div>
1118
+
1119
+ <div class="form-group">
1120
+ <label class="form-label">LÍMITE POR HORA</label>
1121
+ <input class="input" type="number" id="editCampHourlyCap" value="0" min="0" step="1" style="width:100%">
1122
+ <p class="form-hint">0 = desactivado</p>
1123
+ </div>
1124
+
1125
+ <div class="form-group">
1126
+ <label class="form-label">VENTANA DE ENVÍO</label>
1127
+ <div style="display:flex;gap:8px;align-items:center">
1128
+ <input class="input" type="time" id="editCampWindowStart" style="flex:1">
1129
+ <span style="color:var(--text-dim)">—</span>
1130
+ <input class="input" type="time" id="editCampWindowEnd" style="flex:1">
1131
+ </div>
1132
+ <p class="form-hint">Vacío = sin restricción horaria</p>
1133
+ </div>
1134
+
1135
+ <div class="form-group">
1136
+ <label class="form-label">DÍAS ACTIVOS</label>
1137
+ <div style="display:flex;gap:10px;flex-wrap:wrap;margin-top:6px" id="editCampActiveDaysList">
1138
+ <label style="display:flex;align-items:center;gap:4px;cursor:pointer;font-size:12px"><input type="checkbox" value="1" style="cursor:pointer"> Lun</label>
1139
+ <label style="display:flex;align-items:center;gap:4px;cursor:pointer;font-size:12px"><input type="checkbox" value="2" style="cursor:pointer"> Mar</label>
1140
+ <label style="display:flex;align-items:center;gap:4px;cursor:pointer;font-size:12px"><input type="checkbox" value="3" style="cursor:pointer"> Mié</label>
1141
+ <label style="display:flex;align-items:center;gap:4px;cursor:pointer;font-size:12px"><input type="checkbox" value="4" style="cursor:pointer"> Jue</label>
1142
+ <label style="display:flex;align-items:center;gap:4px;cursor:pointer;font-size:12px"><input type="checkbox" value="5" style="cursor:pointer"> Vie</label>
1143
+ <label style="display:flex;align-items:center;gap:4px;cursor:pointer;font-size:12px"><input type="checkbox" value="6" style="cursor:pointer"> Sáb</label>
1144
+ <label style="display:flex;align-items:center;gap:4px;cursor:pointer;font-size:12px"><input type="checkbox" value="7" style="cursor:pointer"> Dom</label>
1145
+ </div>
1146
+ <p class="form-hint">Sin selección = todos los días</p>
1147
+ </div>
1148
+
1149
+ <button class="btn btn-green btn-full" onclick="saveEditCampaign()" style="margin-top:8px;">
1150
+ GUARDAR CAMBIOS
1151
+ </button>
1152
+ </div>
1153
+ </div>
1154
+
1155
+ <!-- MODAL NUEVA CAMPAÑA INBOUND -->
1156
+ <div class="modal-overlay" id="inboundModal">
1157
+ <div class="modal">
1158
+ <button class="modal-close" onclick="closeInboundModal()">✕</button>
1159
+ <div class="modal-title">NUEVA CAMPAÑA INBOUND</div>
1160
+
1161
+ <div class="form-group">
1162
+ <label class="form-label">CUENTA WHATSAPP</label>
1163
+ <select class="input" id="inboundAccount" style="width:100%">
1164
+ <option value="">— seleccionar cuenta —</option>
1165
+ </select>
1166
+ <p class="form-hint">Solo puede haber una campaña inbound activa por cuenta.</p>
1167
+ </div>
1168
+
1169
+ <div class="form-group">
1170
+ <label class="form-label">NOMBRE</label>
1171
+ <input class="input" type="text" id="inboundName" placeholder="Promo marzo — 100 cupones" style="width:100%">
1172
+ </div>
1173
+
1174
+ <div class="form-group">
1175
+ <label class="form-label">MENSAJE A ENVIAR</label>
1176
+ <textarea class="input" id="inboundTemplate" rows="4" placeholder="Hola {{nombre}}! Tu código es PROMO2026. Válido 48hs." style="width:100%"></textarea>
1177
+ <p class="form-hint">Usa {{nombre}} para personalizar con el nombre del contacto.</p>
1178
+ </div>
1179
+
1180
+ <div class="form-group">
1181
+ <label class="form-label">¿A QUIÉN RESPONDER?</label>
1182
+ <select class="input" id="inboundReplyMode" style="width:100%">
1183
+ <option value="once_per_campaign">Una vez por campaña — solo la primera vez que escriben en esta campaña</option>
1184
+ <option value="new_contacts_only">Solo contactos nuevos — primera vez que escriben a este número</option>
1185
+ <option value="always">Siempre — responde a cada mensaje recibido</option>
1186
+ </select>
1187
+ </div>
1188
+
1189
+ <div class="form-group">
1190
+ <label class="form-label">STOCK MÁXIMO DE USOS</label>
1191
+ <input class="input" type="number" id="inboundMaxUses" value="0" min="0" step="1" style="width:100%">
1192
+ <p class="form-hint">0 = ilimitado. Con N usos: la campaña se agota automáticamente al alcanzar el límite.</p>
1193
+ </div>
1194
+
1195
+ <button class="btn btn-green btn-full" onclick="createInboundCampaign()" style="margin-top:8px;">
1196
+ CREAR CAMPAÑA INBOUND
1197
+ </button>
1198
+ </div>
1199
+ </div>
1200
+
1201
+ <!-- MODAL → OUTBOUND desde inbound -->
1202
+ <div class="modal-overlay" id="inboundToOutboundModal">
1203
+ <div class="modal">
1204
+ <button class="modal-close" onclick="closeToOutboundModal()">✕</button>
1205
+ <div class="modal-title">CREAR CAMPAÑA OUTBOUND</div>
1206
+ <p style="font-size:11px;color:var(--text-dim);margin-bottom:18px;letter-spacing:.5px">
1207
+ Los contactos de la campaña inbound se importarán automáticamente.
1208
+ </p>
1209
+
1210
+ <div class="form-group">
1211
+ <label class="form-label">NOMBRE DE LA CAMPAÑA</label>
1212
+ <input class="input" type="text" id="toOutboundName" style="width:100%">
1213
+ </div>
1214
+
1215
+ <div class="form-group">
1216
+ <label class="form-label">TEMPLATE DEL MENSAJE</label>
1217
+ <textarea class="input" id="toOutboundTemplate" rows="4" placeholder="Hola {{nombre}}, te escribimos porque..." style="width:100%"></textarea>
1218
+ <p class="form-hint">Usa {{nombre}} para personalizar. Los contactos se importan con su número.</p>
1219
+ </div>
1220
+
1221
+ <div class="form-group">
1222
+ <label class="form-label">CUENTAS WHATSAPP <span style="font-weight:300;letter-spacing:0">(una o más)</span></label>
1223
+ <div class="accounts-checkbox-list" id="toOutboundAccountsList"></div>
1224
+ </div>
1225
+
1226
+ <div class="form-group">
1227
+ <label class="form-label">DELAY ENTRE MENSAJES (ms)</label>
1228
+ <input class="input" type="number" id="toOutboundDelay" value="3000" min="1000" step="500" style="width:100%">
1229
+ </div>
1230
+
1231
+ <div id="toOutboundContactsInfo" style="font-size:11px;color:var(--green);margin-bottom:14px;letter-spacing:.5px"></div>
1232
+
1233
+ <button class="btn btn-green btn-full" onclick="submitToOutbound()">
1234
+ CREAR CAMPAÑA OUTBOUND
1235
+ </button>
1236
+ </div>
1237
+ </div>
1238
+
1239
+ <!-- MODAL TRACKING -->
1240
+ <div class="modal-overlay" id="trackingModal">
1241
+ <div class="modal tracking-modal">
1242
+ <button class="modal-close" onclick="closeTrackingModal()">✕</button>
1243
+ <div class="modal-title" id="trackingModalTitle">CAMPAIGN TRACKING</div>
1244
+
1245
+ <div class="track-refresh">
1246
+ <div class="track-refresh-dot"></div>
1247
+ <span>Auto-refresh cada 5s</span>
1248
+ <span style="margin-left:auto;color:var(--green-dim)" id="trackingLastUpdate"></span>
1249
+ </div>
1250
+
1251
+ <!-- KPIs -->
1252
+ <div class="kpi-row" id="trackingKpis">
1253
+ <div class="kpi-card"><div class="kpi-val">—</div><div class="kpi-label">ENTREGADOS</div></div>
1254
+ <div class="kpi-card"><div class="kpi-val kpi-cyan">—</div><div class="kpi-label">LEÍDOS</div></div>
1255
+ <div class="kpi-card"><div class="kpi-val kpi-amber">—</div><div class="kpi-label">RESPUESTAS</div></div>
1256
+ </div>
1257
+
1258
+ <!-- Tabs -->
1259
+ <div class="track-tabs">
1260
+ <button class="track-tab active" onclick="switchTrackTab('ack', this)">ACK FUNNEL</button>
1261
+ <button class="track-tab" onclick="switchTrackTab('replies', this)">REPLIES</button>
1262
+ <button class="track-tab" id="trackTabAB" onclick="switchTrackTab('ab', this)" style="display:none">A/B IMAGES</button>
1263
+ </div>
1264
+
1265
+ <!-- ACK Funnel -->
1266
+ <div class="track-pane active" id="trackPaneAck">
1267
+ <div id="trackFunnel">
1268
+ <div class="empty-state" style="border:none;padding:20px">
1269
+ <span class="loading-dots">Cargando</span>
1270
+ </div>
1271
+ </div>
1272
+ </div>
1273
+
1274
+ <!-- Replies -->
1275
+ <div class="track-pane" id="trackPaneReplies">
1276
+ <div class="replies-list" id="trackReplies">
1277
+ <div class="empty-state" style="border:none;padding:20px">
1278
+ <span class="loading-dots">Cargando</span>
1279
+ </div>
1280
+ </div>
1281
+ </div>
1282
+
1283
+ <!-- A/B Images -->
1284
+ <div class="track-pane" id="trackPaneAB">
1285
+ <div id="trackABVariants">
1286
+ <div class="empty-state" style="border:none;padding:20px">
1287
+ <span class="loading-dots">Cargando</span>
1288
+ </div>
1289
+ </div>
1290
+ </div>
1291
+ </div>
1292
+ </div>
1293
+
1294
+ <!-- MODAL IMÁGENES -->
1295
+ <div class="modal-overlay" id="imagesModal">
1296
+ <div class="modal" style="max-width:480px">
1297
+ <button class="modal-close" onclick="closeImagesModal()">✕</button>
1298
+ <div class="modal-title">IMÁGENES A/B</div>
1299
+ <div id="imagesList" style="margin-bottom:16px"></div>
1300
+ <button class="btn btn-amber btn-sm" id="addImageBtn" onclick="triggerImageUpload()">+ AGREGAR IMAGEN</button>
1301
+ <div style="color:var(--text-dim);font-size:11px;margin-top:8px">Máx. 5 variantes · JPG, PNG, GIF, WEBP · 5MB</div>
1302
+ </div>
1303
+ </div>
1304
+
1305
+ <!-- TOASTS -->
1306
+ <div class="toast-container" id="toastContainer"></div>
1307
+
1308
+ <!-- HIDDEN FILE INPUT -->
1309
+ <input type="file" id="csvFileInput" accept=".csv,.xlsx,.xls" style="display:none" onchange="handleFileUpload(event)">
1310
+ <input type="file" id="couponFileInput" accept=".txt,.csv" style="display:none" onchange="handleCouponUpload(event)">
1311
+ <input type="file" id="imageFileInput" accept=".jpg,.jpeg,.png,.gif,.webp" style="display:none" onchange="handleImageUpload(event)">
1312
+
1313
+ <script>
1314
+ const API = ''; // mismo origen
1315
+ let accounts = [];
1316
+ let campaigns = [];
1317
+ let inboundCampaigns = [];
1318
+ let activeCouponCampaignId = null;
1319
+ let pollTimer = null;
1320
+ let qrPollTimer = null;
1321
+ let activeQrAccountId = null;
1322
+ let activeCsvCampaignId = null;
1323
+ let activeImageCampaignId = null;
1324
+ let activeEditCampaignId = null;
1325
+
1326
+ // ── RELOJ ──
1327
+ function updateClock() {
1328
+ const now = new Date();
1329
+ const h = String(now.getHours()).padStart(2,'0');
1330
+ const m = String(now.getMinutes()).padStart(2,'0');
1331
+ const s = String(now.getSeconds()).padStart(2,'0');
1332
+ document.getElementById('clock').textContent = `${h}:${m}:${s}`;
1333
+ }
1334
+ setInterval(updateClock, 1000);
1335
+ updateClock();
1336
+
1337
+ // ── HEALTH CHECK ──
1338
+ async function checkHealth() {
1339
+ try {
1340
+ const res = await fetch(`${API}/health`);
1341
+ const ok = res.ok;
1342
+ document.getElementById('serverDot').className = `status-dot${ok ? '' : ' offline'}`;
1343
+ document.getElementById('serverLabel').textContent = ok ? 'ONLINE' : 'OFFLINE';
1344
+ } catch {
1345
+ document.getElementById('serverDot').className = 'status-dot offline';
1346
+ document.getElementById('serverLabel').textContent = 'OFFLINE';
1347
+ }
1348
+ }
1349
+ setInterval(checkHealth, 10000);
1350
+ checkHealth();
1351
+
1352
+ // ── TOAST ──
1353
+ function toast(msg, type = 'success') {
1354
+ const container = document.getElementById('toastContainer');
1355
+ const el = document.createElement('div');
1356
+ el.className = `toast ${type}`;
1357
+ el.textContent = msg;
1358
+ container.appendChild(el);
1359
+ setTimeout(() => {
1360
+ el.classList.add('out');
1361
+ setTimeout(() => el.remove(), 200);
1362
+ }, 4000);
1363
+ }
1364
+
1365
+ // ── API HELPERS ──
1366
+ async function api(method, path, body = null) {
1367
+ const opts = {
1368
+ method,
1369
+ headers: body && !(body instanceof FormData) ? { 'Content-Type': 'application/json' } : {},
1370
+ body: body instanceof FormData ? body : (body ? JSON.stringify(body) : null),
1371
+ };
1372
+ const res = await fetch(`${API}${path}`, opts);
1373
+ const data = await res.json().catch(() => ({}));
1374
+ if (!res.ok) throw new Error(data.error || `Error ${res.status}`);
1375
+ return data;
1376
+ }
1377
+
1378
+ // ── POLLING ──
1379
+ function schedulePoll() {
1380
+ clearTimeout(pollTimer);
1381
+ const hasActiveCampaigns = campaigns.some(c => c.status === 'running');
1382
+ const hasPendingAccounts = accounts.some(a => a.status === 'pending' || a.status === 'initializing');
1383
+ const delay = (hasActiveCampaigns || hasPendingAccounts) ? 3000 : 10000;
1384
+ pollTimer = setTimeout(async () => {
1385
+ await Promise.all([loadAccounts(), loadCampaigns(), loadInbound()]);
1386
+ schedulePoll();
1387
+ }, delay);
1388
+ }
1389
+
1390
+ // ── ACCOUNTS ──
1391
+ async function loadAccounts() {
1392
+ try {
1393
+ accounts = await api('GET', '/api/accounts');
1394
+ renderAccounts();
1395
+ } catch (e) {
1396
+ console.error('Error cargando cuentas:', e);
1397
+ }
1398
+ }
1399
+
1400
+ function renderAccounts() {
1401
+ const maxAcc = 3;
1402
+ const active = accounts.filter(a => a.status !== 'disconnected').length;
1403
+ document.getElementById('accountCount').textContent = `${active} / ${maxAcc}`;
1404
+
1405
+ const list = document.getElementById('accountsList');
1406
+ if (!accounts.length) {
1407
+ list.innerHTML = `<div class="empty-state"><div class="empty-icon">📱</div>No hay cuentas registradas.<br>Crea una para comenzar.</div>`;
1408
+ return;
1409
+ }
1410
+
1411
+ list.innerHTML = accounts.map(a => {
1412
+ const statusClass = a.status === 'ready' ? 'badge-green' :
1413
+ a.status === 'pending' || a.status === 'authenticated' || a.status === 'initializing' ? 'badge-amber' : 'badge-red';
1414
+ const statusDot = a.status === 'ready' ? '●' : a.status === 'disconnected' ? '✕' : '◌';
1415
+ const statusLabel = a.status.toUpperCase();
1416
+
1417
+ return `
1418
+ <div class="account-card ${a.status}" data-id="${a.id}">
1419
+ <div class="card-row">
1420
+ <div>
1421
+ <div class="account-id">${escHtml(a.id)}</div>
1422
+ <div class="account-phone">${a.phone ? '+' + a.phone : '—'}</div>
1423
+ </div>
1424
+ <div class="card-actions">
1425
+ <span class="badge ${statusClass}"><span class="badge-dot"></span>${statusLabel}</span>
1426
+ </div>
1427
+ </div>
1428
+ <div class="card-row">
1429
+ <div class="card-actions" style="flex-wrap:wrap;gap:6px">
1430
+ ${a.status !== 'ready' ? `<button class="btn btn-amber btn-sm" onclick="openQrModal('${escHtml(a.id)}')">QR</button>` : ''}
1431
+ <button class="btn btn-amber btn-sm" onclick="resetAccount('${escHtml(a.id)}')" title="Borrar sesión y forzar nuevo QR">RESET</button>
1432
+ <button class="btn btn-red btn-sm" onclick="deleteAccount('${escHtml(a.id)}')">DEL</button>
1433
+ <div style="display:flex;align-items:center;gap:4px;margin-left:auto">
1434
+ <span style="font-size:10px;color:var(--text-dim)">LÍMITE/DÍA:</span>
1435
+ <input type="number" class="input" value="${a.daily_limit || 50}" min="1" max="999"
1436
+ id="dailyLimit_${escHtml(a.id)}" style="width:56px;padding:2px 6px;font-size:11px;text-align:center">
1437
+ <button class="btn btn-amber btn-sm" style="padding:2px 8px;font-size:10px"
1438
+ onclick="updateDailyLimit('${escHtml(a.id)}')">SET</button>
1439
+ </div>
1440
+ </div>
1441
+ </div>
1442
+ </div>`;
1443
+ }).join('');
1444
+ }
1445
+
1446
+ function toggleAddAccountForm() {
1447
+ const form = document.getElementById('addAccountForm');
1448
+ form.classList.toggle('visible');
1449
+ if (form.classList.contains('visible')) {
1450
+ document.getElementById('newAccountId').focus();
1451
+ }
1452
+ }
1453
+
1454
+ async function createAccount() {
1455
+ const id = document.getElementById('newAccountId').value.trim();
1456
+ if (!id) { toast('Ingresa un ID para la cuenta', 'error'); return; }
1457
+ try {
1458
+ await api('POST', '/api/accounts', { id });
1459
+ toast(`Cuenta "${id}" inicializando...`, 'info');
1460
+ document.getElementById('newAccountId').value = '';
1461
+ document.getElementById('addAccountForm').classList.remove('visible');
1462
+ await loadAccounts();
1463
+ schedulePoll();
1464
+ // Auto-abrir QR
1465
+ setTimeout(() => openQrModal(id), 1500);
1466
+ } catch (e) {
1467
+ toast(e.message, 'error');
1468
+ }
1469
+ }
1470
+
1471
+ async function deleteAccount(id) {
1472
+ if (!confirm(`¿Eliminar la cuenta "${id}"?`)) return;
1473
+ try {
1474
+ await api('DELETE', `/api/accounts/${id}`);
1475
+ toast(`Cuenta "${id}" eliminada`, 'success');
1476
+ await loadAccounts();
1477
+ } catch (e) {
1478
+ toast(e.message, 'error');
1479
+ }
1480
+ }
1481
+
1482
+ // ── QR MODAL ──
1483
+ async function openQrModal(accountId) {
1484
+ activeQrAccountId = accountId;
1485
+ document.getElementById('qrModal').classList.add('visible');
1486
+ document.getElementById('qrContainer').innerHTML = `<p class="qr-scanning loading-dots">Obteniendo QR</p>`;
1487
+ document.getElementById('qrStatusText').textContent = 'Abre WhatsApp → Dispositivos vinculados → Vincular dispositivo';
1488
+ startQrPoll();
1489
+ }
1490
+
1491
+ function closeQrModal() {
1492
+ document.getElementById('qrModal').classList.remove('visible');
1493
+ clearInterval(qrPollTimer);
1494
+ activeQrAccountId = null;
1495
+ }
1496
+
1497
+ function startQrPoll() {
1498
+ clearInterval(qrPollTimer);
1499
+ qrPollTimer = setInterval(async () => {
1500
+ if (!activeQrAccountId) return;
1501
+ try {
1502
+ // Verificar estado
1503
+ const status = await api('GET', `/api/accounts/${activeQrAccountId}/status`);
1504
+ if (status.status === 'ready') {
1505
+ document.getElementById('qrContainer').innerHTML = `<div style="color:var(--green);font-size:32px;margin:20px 0">✓</div><p style="color:var(--green);font-size:12px;letter-spacing:1px">AUTENTICADO</p>`;
1506
+ document.getElementById('qrStatusText').textContent = 'Cuenta lista para usar';
1507
+ clearInterval(qrPollTimer);
1508
+ setTimeout(() => {
1509
+ closeQrModal();
1510
+ loadAccounts();
1511
+ toast(`Cuenta autenticada y lista`, 'success');
1512
+ }, 1500);
1513
+ return;
1514
+ }
1515
+ // Obtener QR
1516
+ const qrData = await api('GET', `/api/accounts/${activeQrAccountId}/qr`);
1517
+ if (qrData.qr) {
1518
+ document.getElementById('qrContainer').innerHTML = `
1519
+ <img class="qr-img" src="${qrData.qr}" alt="QR Code">
1520
+ <p class="qr-scanning">● ESPERANDO ESCANEO</p>`;
1521
+ }
1522
+ } catch (e) {
1523
+ // QR no disponible aún, seguir esperando
1524
+ }
1525
+ }, 2000);
1526
+ }
1527
+
1528
+ // ── CAMPAIGNS ──
1529
+ async function loadCampaigns() {
1530
+ try {
1531
+ campaigns = await api('GET', '/api/campaigns');
1532
+ renderCampaigns();
1533
+ } catch (e) {
1534
+ console.error('Error cargando campañas:', e);
1535
+ }
1536
+ }
1537
+
1538
+ function renderCampaigns() {
1539
+ const running = campaigns.filter(c => c.status === 'running').length;
1540
+ document.getElementById('campaignCount').textContent = `${running} ejecutando`;
1541
+
1542
+ const list = document.getElementById('campaignsList');
1543
+ if (!campaigns.length) {
1544
+ list.innerHTML = `<div class="empty-state"><div class="empty-icon">📤</div>No hay campañas.<br>Crea una y sube un CSV de contactos.</div>`;
1545
+ return;
1546
+ }
1547
+
1548
+ list.innerHTML = campaigns.map(c => {
1549
+ const s = c.stats || { sent: 0, failed: 0, pending: 0, total: 0 };
1550
+ const pct = s.total > 0 ? Math.round(((s.sent + s.failed) / s.total) * 100) : 0;
1551
+
1552
+ const statusBadge = {
1553
+ pending: `<span class="badge badge-dim"><span class="badge-dot"></span>PENDING</span>`,
1554
+ running: `<span class="badge badge-running"><span class="badge-dot"></span>RUNNING</span>`,
1555
+ paused: `<span class="badge badge-amber"><span class="badge-dot"></span>PAUSED</span>`,
1556
+ done: `<span class="badge badge-green"><span class="badge-dot"></span>DONE</span>`,
1557
+ cancelled: `<span class="badge badge-red"><span class="badge-dot"></span>CANCELLED</span>`,
1558
+ scheduled: `<span class="badge badge-amber"><span class="badge-dot"></span>⏰ SCHEDULED</span>`,
1559
+ }[c.status] || `<span class="badge badge-dim">${c.status.toUpperCase()}</span>`;
1560
+
1561
+ const imgCount = (c.images || []).length;
1562
+ const imgLabel = imgCount === 0 ? 'IMAGEN' : imgCount === 1 ? '📷 1 IMG' : `📷 ${imgCount} IMGS`;
1563
+
1564
+ const actions = {
1565
+ pending: `
1566
+ <button class="btn btn-amber btn-sm" onclick="triggerCsvUpload(${c.id})">UPLOAD CSV</button>
1567
+ <button class="btn btn-amber btn-sm" onclick="openImagesModal(${c.id})">${imgLabel}</button>
1568
+ ${s.total > 0 ? `<button class="btn btn-green btn-sm" onclick="startCampaign(${c.id})">START</button>` : ''}
1569
+ <button class="btn btn-amber btn-sm" onclick="openEditCampaignModal(${c.id}, '${escHtml(c.name)}', \`${escHtml(c.template)}\`, ${c.delay_ms}, ${c.jitter_ms ?? 2000}, ${c.typing_sim ?? 1}, ${c.burst_size ?? 0}, ${c.burst_pause_ms ?? 60000}, ${c.daily_cap ?? 0}, ${c.hourly_cap ?? 0}, ${JSON.stringify(c.send_window_start || '')}, ${JSON.stringify(c.send_window_end || '')}, ${JSON.stringify(c.active_days || '')})">EDIT</button>`,
1570
+ running: `
1571
+ <button class="btn btn-amber btn-sm" onclick="pauseCampaign(${c.id})">PAUSE</button>
1572
+ <button class="btn btn-red btn-sm" onclick="cancelCampaign(${c.id})">CANCEL</button>`,
1573
+ paused: `
1574
+ <button class="btn btn-green btn-sm" onclick="startCampaign(${c.id})">RESUME</button>
1575
+ <button class="btn btn-red btn-sm" onclick="cancelCampaign(${c.id})">CANCEL</button>`,
1576
+ done: `<button class="btn btn-red btn-sm" onclick="deleteCampaign(${c.id}, '${escHtml(c.name)}')">DEL</button>`,
1577
+ cancelled: `<button class="btn btn-red btn-sm" onclick="deleteCampaign(${c.id}, '${escHtml(c.name)}')">DEL</button>`,
1578
+ scheduled: `
1579
+ <button class="btn btn-green btn-sm" onclick="startCampaign(${c.id})">RESUME</button>
1580
+ <button class="btn btn-red btn-sm" onclick="cancelCampaign(${c.id})">CANCEL</button>
1581
+ <button class="btn btn-red btn-sm" onclick="deleteCampaign(${c.id}, '${escHtml(c.name)}')">DEL</button>`,
1582
+ }[c.status] || '';
1583
+
1584
+ const accountPills = (c.accounts && c.accounts.length > 0 ? c.accounts : (c.account_id ? [c.account_id] : []))
1585
+ .map(a => `<span class="account-pill">${escHtml(a)}</span>`).join('');
1586
+
1587
+ return `
1588
+ <div class="campaign-card ${c.status}">
1589
+ <div class="card-row">
1590
+ <div>
1591
+ <div class="campaign-name">${escHtml(c.name)}</div>
1592
+ <div class="campaign-meta">${accountPills} <span style="color:var(--text-dim)">· ${c.delay_ms}ms delay</span></div>
1593
+ </div>
1594
+ ${statusBadge}
1595
+ </div>
1596
+ ${s.total > 0 ? `
1597
+ <div class="progress-wrap">
1598
+ <div class="progress-label">
1599
+ <span>PROGRESO</span>
1600
+ <span class="progress-pct">${pct}%</span>
1601
+ </div>
1602
+ <div class="progress-bar">
1603
+ <div class="progress-fill ${c.status === 'running' ? 'running' : ''}" style="width:${pct}%"></div>
1604
+ </div>
1605
+ </div>
1606
+ <div class="stats-row">
1607
+ <div class="stat-item stat-sent"><span class="stat-val">${s.sent}</span> enviados</div>
1608
+ <div class="stat-item stat-failed"><span class="stat-val">${s.failed}</span> fallidos</div>
1609
+ <div class="stat-item stat-pending"><span class="stat-val">${s.pending}</span> pendientes</div>
1610
+ <div class="stat-item" style="color:var(--text-dim);margin-left:auto"><span class="stat-val">${s.total}</span> total</div>
1611
+ </div>` : `
1612
+ <div class="stats-row"><div class="stat-item stat-pending">Sin contactos — sube un CSV</div></div>
1613
+ `}
1614
+ ${c.template ? `<div class="template-preview">${escHtml(c.template)}</div>` : ''}
1615
+ ${imgCount > 0 ? `<div style="color:var(--green-dim);font-size:11px;margin-top:4px">📷 ${imgCount === 1 ? '1 imagen adjunta' : `A/B testing: ${imgCount} variantes`}</div>` : ''}
1616
+ <div class="card-actions" style="gap:8px;flex-wrap:wrap">
1617
+ ${actions}
1618
+ ${s.total > 0 ? `<button class="btn btn-green btn-sm" style="margin-left:auto" onclick="openTrackingModal(${c.id}, '${escHtml(c.name)}')">TRACKING</button>` : ''}
1619
+ </div>
1620
+ </div>`;
1621
+ }).join('');
1622
+ }
1623
+
1624
+ // ── CAMPAIGN MODAL ──
1625
+ function openNewCampaignModal() {
1626
+ const list = document.getElementById('campAccountsList');
1627
+ const readyAccounts = accounts.filter(a => a.status === 'ready');
1628
+
1629
+ if (readyAccounts.length === 0) {
1630
+ list.innerHTML = `<div class="no-accounts-msg">No hay cuentas READY disponibles</div>`;
1631
+ } else {
1632
+ list.innerHTML = readyAccounts.map(a => `
1633
+ <label class="account-checkbox-item">
1634
+ <input type="checkbox" value="${escHtml(a.id)}">
1635
+ <div class="account-checkbox-info">
1636
+ <span class="account-checkbox-id">${escHtml(a.id)}</span>
1637
+ <span class="account-checkbox-phone">${a.phone ? '+' + a.phone : '—'}</span>
1638
+ </div>
1639
+ <span class="badge badge-green" style="font-size:8px;padding:2px 5px">● READY</span>
1640
+ </label>
1641
+ `).join('');
1642
+ }
1643
+
1644
+ document.getElementById('campaignModal').classList.add('visible');
1645
+ document.getElementById('campName').focus();
1646
+ }
1647
+
1648
+ function closeCampaignModal() {
1649
+ document.getElementById('campaignModal').classList.remove('visible');
1650
+ }
1651
+
1652
+ async function createCampaign() {
1653
+ const name = document.getElementById('campName').value.trim();
1654
+ const checked = document.querySelectorAll('#campAccountsList input[type="checkbox"]:checked');
1655
+ const account_ids = Array.from(checked).map(cb => cb.value);
1656
+ const template = document.getElementById('campTemplate').value.trim();
1657
+ const delay_ms = parseInt(document.getElementById('campDelay').value, 10);
1658
+ const jitter_ms = parseInt(document.getElementById('campJitter').value, 10);
1659
+ const typing_sim = document.getElementById('campTyping').checked ? 1 : 0;
1660
+ const burst_size = parseInt(document.getElementById('campBurstSize').value, 10);
1661
+ const burst_pause_ms = parseInt(document.getElementById('campBurstPause').value, 10);
1662
+
1663
+ if (!name) { toast('Ingresa un nombre para la campaña', 'error'); return; }
1664
+ if (account_ids.length === 0) { toast('Selecciona al menos una cuenta WhatsApp', 'error'); return; }
1665
+ if (!template) { toast('El template no puede estar vacío', 'error'); return; }
1666
+ if (isNaN(delay_ms) || delay_ms < 1000) { toast('El delay mínimo es 1000ms', 'error'); return; }
1667
+
1668
+ const daily_cap = parseInt(document.getElementById('campDailyCap').value, 10) || 0;
1669
+ const hourly_cap = parseInt(document.getElementById('campHourlyCap').value, 10) || 0;
1670
+ const send_window_start = document.getElementById('campWindowStart').value || null;
1671
+ const send_window_end = document.getElementById('campWindowEnd').value || null;
1672
+ const activeDayCbs = document.querySelectorAll('#campActiveDaysList input[type="checkbox"]:checked');
1673
+ const active_days = activeDayCbs.length > 0 ? Array.from(activeDayCbs).map(cb => cb.value).join(',') : null;
1674
+
1675
+ try {
1676
+ const camp = await api('POST', '/api/campaigns', {
1677
+ name, account_ids, template, delay_ms, jitter_ms, typing_sim, burst_size, burst_pause_ms,
1678
+ daily_cap, hourly_cap, send_window_start, send_window_end, active_days,
1679
+ });
1680
+ toast(`Campaña "${name}" creada`, 'success');
1681
+ closeCampaignModal();
1682
+ // Limpiar form
1683
+ document.getElementById('campName').value = '';
1684
+ document.getElementById('campTemplate').value = '';
1685
+ document.getElementById('campDelay').value = '3000';
1686
+ document.getElementById('campJitter').value = '2000';
1687
+ document.getElementById('campTyping').checked = true;
1688
+ document.getElementById('campBurstSize').value = '0';
1689
+ document.getElementById('campBurstPause').value = '60000';
1690
+ document.getElementById('campDailyCap').value = '0';
1691
+ document.getElementById('campHourlyCap').value = '0';
1692
+ document.getElementById('campWindowStart').value = '';
1693
+ document.getElementById('campWindowEnd').value = '';
1694
+ document.querySelectorAll('#campActiveDaysList input[type="checkbox"]').forEach(cb => cb.checked = false);
1695
+ await loadCampaigns();
1696
+ // Sugerir upload de CSV
1697
+ setTimeout(() => toast('Sube un CSV de contactos para iniciar', 'info'), 500);
1698
+ } catch (e) {
1699
+ toast(e.message, 'error');
1700
+ }
1701
+ }
1702
+
1703
+ // ── CAMPAIGN ACTIONS ──
1704
+ async function startCampaign(id) {
1705
+ try {
1706
+ await api('POST', `/api/campaigns/${id}/start`);
1707
+ toast('Campaña iniciada', 'success');
1708
+ await loadCampaigns();
1709
+ schedulePoll();
1710
+ } catch (e) {
1711
+ toast(e.message, 'error');
1712
+ }
1713
+ }
1714
+
1715
+ async function pauseCampaign(id) {
1716
+ try {
1717
+ await api('POST', `/api/campaigns/${id}/pause`);
1718
+ toast('Campaña pausada', 'info');
1719
+ await loadCampaigns();
1720
+ } catch (e) {
1721
+ toast(e.message, 'error');
1722
+ }
1723
+ }
1724
+
1725
+ async function cancelCampaign(id) {
1726
+ if (!confirm('¿Cancelar esta campaña? Los mensajes no enviados quedarán pendientes.')) return;
1727
+ try {
1728
+ await api('POST', `/api/campaigns/${id}/cancel`);
1729
+ toast('Campaña cancelada', 'info');
1730
+ await loadCampaigns();
1731
+ } catch (e) {
1732
+ toast(e.message, 'error');
1733
+ }
1734
+ }
1735
+
1736
+ // ── CSV UPLOAD ──
1737
+ function triggerCsvUpload(campaignId) {
1738
+ activeCsvCampaignId = campaignId;
1739
+ document.getElementById('csvFileInput').value = '';
1740
+ document.getElementById('csvFileInput').click();
1741
+ }
1742
+
1743
+ async function handleFileUpload(event) {
1744
+ const file = event.target.files[0];
1745
+ if (!file || !activeCsvCampaignId) return;
1746
+
1747
+ const formData = new FormData();
1748
+ formData.append('file', file);
1749
+
1750
+ toast(`Subiendo "${file.name}"...`, 'info');
1751
+
1752
+ try {
1753
+ const result = await fetch(`${API}/api/campaigns/${activeCsvCampaignId}/contacts`, {
1754
+ method: 'POST',
1755
+ body: formData,
1756
+ });
1757
+ const data = await result.json();
1758
+ if (!result.ok) throw new Error(data.error || 'Error al subir');
1759
+ toast(`${data.imported} contactos importados`, 'success');
1760
+ await loadCampaigns();
1761
+ } catch (e) {
1762
+ toast(e.message, 'error');
1763
+ } finally {
1764
+ activeCsvCampaignId = null;
1765
+ }
1766
+ }
1767
+
1768
+ // ── DELETE CAMPAIGN ──
1769
+ async function deleteCampaign(id, name) {
1770
+ if (!confirm(`¿Eliminar la campaña "${name}"? Esta acción no se puede deshacer.`)) return;
1771
+ try {
1772
+ await api('DELETE', `/api/campaigns/${id}`);
1773
+ toast(`Campaña "${name}" eliminada`, 'success');
1774
+ await loadCampaigns();
1775
+ } catch (e) {
1776
+ toast(e.message, 'error');
1777
+ }
1778
+ }
1779
+
1780
+ // ── EDIT CAMPAIGN MODAL ──
1781
+ function openEditCampaignModal(id, name, template, delay_ms, jitter_ms, typing_sim, burst_size, burst_pause_ms,
1782
+ daily_cap, hourly_cap, send_window_start, send_window_end, active_days) {
1783
+ activeEditCampaignId = id;
1784
+ document.getElementById('editCampName').value = name;
1785
+ document.getElementById('editCampTemplate').value = template;
1786
+ document.getElementById('editCampDelay').value = delay_ms;
1787
+ document.getElementById('editCampJitter').value = jitter_ms ?? 2000;
1788
+ document.getElementById('editCampTyping').checked = typing_sim !== 0;
1789
+ document.getElementById('editCampBurstSize').value = burst_size ?? 0;
1790
+ document.getElementById('editCampBurstPause').value = burst_pause_ms ?? 60000;
1791
+ document.getElementById('editCampDailyCap').value = daily_cap ?? 0;
1792
+ document.getElementById('editCampHourlyCap').value = hourly_cap ?? 0;
1793
+ document.getElementById('editCampWindowStart').value = send_window_start || '';
1794
+ document.getElementById('editCampWindowEnd').value = send_window_end || '';
1795
+ // Marcar días activos
1796
+ const activeDaysArr = active_days ? active_days.split(',') : [];
1797
+ document.querySelectorAll('#editCampActiveDaysList input[type="checkbox"]').forEach(cb => {
1798
+ cb.checked = activeDaysArr.includes(cb.value);
1799
+ });
1800
+ document.getElementById('editCampaignModal').classList.add('visible');
1801
+ document.getElementById('editCampName').focus();
1802
+ }
1803
+
1804
+ function closeEditCampaignModal() {
1805
+ document.getElementById('editCampaignModal').classList.remove('visible');
1806
+ activeEditCampaignId = null;
1807
+ }
1808
+
1809
+ async function saveEditCampaign() {
1810
+ const name = document.getElementById('editCampName').value.trim();
1811
+ const template = document.getElementById('editCampTemplate').value.trim();
1812
+ const delay_ms = parseInt(document.getElementById('editCampDelay').value, 10);
1813
+ const jitter_ms = parseInt(document.getElementById('editCampJitter').value, 10);
1814
+ const typing_sim = document.getElementById('editCampTyping').checked ? 1 : 0;
1815
+ const burst_size = parseInt(document.getElementById('editCampBurstSize').value, 10);
1816
+ const burst_pause_ms = parseInt(document.getElementById('editCampBurstPause').value, 10);
1817
+
1818
+ if (!name) { toast('El nombre no puede estar vacío', 'error'); return; }
1819
+ if (!template) { toast('El template no puede estar vacío', 'error'); return; }
1820
+ if (isNaN(delay_ms) || delay_ms < 1000) { toast('El delay mínimo es 1000ms', 'error'); return; }
1821
+
1822
+ const daily_cap = parseInt(document.getElementById('editCampDailyCap').value, 10) || 0;
1823
+ const hourly_cap = parseInt(document.getElementById('editCampHourlyCap').value, 10) || 0;
1824
+ const send_window_start = document.getElementById('editCampWindowStart').value || null;
1825
+ const send_window_end = document.getElementById('editCampWindowEnd').value || null;
1826
+ const editActiveDayCbs = document.querySelectorAll('#editCampActiveDaysList input[type="checkbox"]:checked');
1827
+ const active_days = editActiveDayCbs.length > 0 ? Array.from(editActiveDayCbs).map(cb => cb.value).join(',') : null;
1828
+
1829
+ try {
1830
+ await api('PATCH', `/api/campaigns/${activeEditCampaignId}`, {
1831
+ name, template, delay_ms, jitter_ms, typing_sim, burst_size, burst_pause_ms,
1832
+ daily_cap, hourly_cap, send_window_start, send_window_end, active_days,
1833
+ });
1834
+ toast('Campaña actualizada', 'success');
1835
+ closeEditCampaignModal();
1836
+ await loadCampaigns();
1837
+ } catch (e) {
1838
+ toast(e.message, 'error');
1839
+ }
1840
+ }
1841
+
1842
+ // ── KEYBOARD SHORTCUTS ──
1843
+ document.addEventListener('keydown', (e) => {
1844
+ if (e.key === 'Escape') {
1845
+ closeQrModal();
1846
+ closeCampaignModal();
1847
+ closeEditCampaignModal();
1848
+ closeTrackingModal();
1849
+ }
1850
+ if (e.key === 'Enter' && document.getElementById('addAccountForm').classList.contains('visible')) {
1851
+ createAccount();
1852
+ }
1853
+ });
1854
+
1855
+ // ── UTILS ──
1856
+ function escHtml(str) {
1857
+ return String(str)
1858
+ .replace(/&/g, '&amp;')
1859
+ .replace(/</g, '&lt;')
1860
+ .replace(/>/g, '&gt;')
1861
+ .replace(/"/g, '&quot;');
1862
+ }
1863
+
1864
+ // ── TRACKING MODAL ──
1865
+ let activeTrackingId = null;
1866
+ let trackingRefreshTimer = null;
1867
+
1868
+ async function openTrackingModal(campaignId, campaignName) {
1869
+ activeTrackingId = campaignId;
1870
+ document.getElementById('trackingModalTitle').textContent = `TRACKING — ${campaignName}`;
1871
+ document.getElementById('trackingModal').classList.add('visible');
1872
+
1873
+ // Reset UI
1874
+ document.getElementById('trackFunnel').innerHTML = `<div class="empty-state" style="border:none;padding:20px"><span class="loading-dots">Cargando</span></div>`;
1875
+ document.getElementById('trackReplies').innerHTML = `<div class="empty-state" style="border:none;padding:20px"><span class="loading-dots">Cargando</span></div>`;
1876
+ document.getElementById('trackABVariants').innerHTML = `<div class="empty-state" style="border:none;padding:20px"><span class="loading-dots">Cargando</span></div>`;
1877
+
1878
+ await refreshTracking();
1879
+ trackingRefreshTimer = setInterval(refreshTracking, 5000);
1880
+ }
1881
+
1882
+ function closeTrackingModal() {
1883
+ document.getElementById('trackingModal').classList.remove('visible');
1884
+ clearInterval(trackingRefreshTimer);
1885
+ activeTrackingId = null;
1886
+ }
1887
+
1888
+ function switchTrackTab(tab, btn) {
1889
+ document.querySelectorAll('.track-tab').forEach(t => t.classList.remove('active'));
1890
+ document.querySelectorAll('.track-pane').forEach(p => p.classList.remove('active'));
1891
+ btn.classList.add('active');
1892
+ const paneId = tab === 'ack' ? 'trackPaneAck' : tab === 'ab' ? 'trackPaneAB' : 'trackPaneReplies';
1893
+ document.getElementById(paneId).classList.add('active');
1894
+ }
1895
+
1896
+ async function refreshTracking() {
1897
+ if (!activeTrackingId) return;
1898
+ try {
1899
+ const [tracking, replies] = await Promise.all([
1900
+ api('GET', `/api/campaigns/${activeTrackingId}/tracking`),
1901
+ api('GET', `/api/campaigns/${activeTrackingId}/replies`),
1902
+ ]);
1903
+ renderTrackingKpis(tracking, replies);
1904
+ renderAckFunnel(tracking);
1905
+ renderReplies(replies);
1906
+ renderABVariants(tracking.image_variants || []);
1907
+ const now = new Date();
1908
+ document.getElementById('trackingLastUpdate').textContent =
1909
+ `actualizado ${String(now.getHours()).padStart(2,'0')}:${String(now.getMinutes()).padStart(2,'0')}:${String(now.getSeconds()).padStart(2,'0')}`;
1910
+ } catch (e) {
1911
+ console.error('Error refreshing tracking:', e);
1912
+ }
1913
+ }
1914
+
1915
+ function renderTrackingKpis(t, replies) {
1916
+ const total = t.total || 0;
1917
+ const ackMap = {};
1918
+ (t.ack || []).forEach(row => { ackMap[row.ack_status] = row.count; });
1919
+
1920
+ const delivered = (ackMap['delivered'] || 0) + (ackMap['read'] || 0) + (ackMap['played'] || 0);
1921
+ const read = (ackMap['read'] || 0) + (ackMap['played'] || 0);
1922
+ const replyCount = t.replies || 0;
1923
+
1924
+ const pct = n => total > 0 ? Math.round((n / total) * 100) + '%' : '—';
1925
+
1926
+ document.getElementById('trackingKpis').innerHTML = `
1927
+ <div class="kpi-card">
1928
+ <div class="kpi-val">${pct(delivered)}</div>
1929
+ <div class="kpi-label">ENTREGADOS</div>
1930
+ </div>
1931
+ <div class="kpi-card">
1932
+ <div class="kpi-val kpi-cyan">${pct(read)}</div>
1933
+ <div class="kpi-label">LEÍDOS</div>
1934
+ </div>
1935
+ <div class="kpi-card">
1936
+ <div class="kpi-val kpi-amber">${replyCount}</div>
1937
+ <div class="kpi-label">RESPUESTAS</div>
1938
+ </div>
1939
+ `;
1940
+ }
1941
+
1942
+ function renderAckFunnel(t) {
1943
+ const total = t.total || 0;
1944
+ const sent = t.sent || 0;
1945
+ const ackMap = {};
1946
+ (t.ack || []).forEach(row => { ackMap[row.ack_status] = row.count; });
1947
+
1948
+ const delivered = (ackMap['delivered'] || 0) + (ackMap['read'] || 0) + (ackMap['played'] || 0);
1949
+ const read = (ackMap['read'] || 0) + (ackMap['played'] || 0);
1950
+ const played = ackMap['played'] || 0;
1951
+ const errorCount = ackMap['error'] || 0;
1952
+ const replyCount = t.replies || 0;
1953
+
1954
+ const rows = [
1955
+ { label: 'TOTAL', count: total, cls: 'bar-total', base: total },
1956
+ { label: 'ENVIADOS', count: sent, cls: 'bar-sent', base: total },
1957
+ { label: 'EN SERVER', count: ackMap['server'] || 0, cls: 'bar-server', base: total },
1958
+ { label: 'ENTREGADOS', count: delivered, cls: 'bar-delivered', base: total },
1959
+ { label: 'LEÍDOS', count: read, cls: 'bar-read', base: total },
1960
+ { label: 'REPRODUCIDOS', count: played, cls: 'bar-played', base: total },
1961
+ { label: 'ERRORES', count: errorCount, cls: 'bar-error', base: total },
1962
+ { label: 'RESPUESTAS', count: replyCount, cls: 'bar-replies', base: total },
1963
+ ];
1964
+
1965
+ document.getElementById('trackFunnel').innerHTML = rows.map(r => {
1966
+ const pct = r.base > 0 ? Math.round((r.count / r.base) * 100) : 0;
1967
+ const width = r.base > 0 ? Math.max(pct, r.count > 0 ? 1 : 0) : 0;
1968
+ return `
1969
+ <div class="funnel-row">
1970
+ <div class="funnel-label">${r.label}</div>
1971
+ <div class="funnel-bar-wrap">
1972
+ <div class="funnel-bar ${r.cls}" style="width:${width}%"></div>
1973
+ </div>
1974
+ <div class="funnel-count">${r.count.toLocaleString()}</div>
1975
+ <div class="funnel-pct">${pct}%</div>
1976
+ </div>`;
1977
+ }).join('');
1978
+ }
1979
+
1980
+ function renderReplies(replies) {
1981
+ const container = document.getElementById('trackReplies');
1982
+ if (!replies.length) {
1983
+ container.innerHTML = `<div class="empty-state" style="border:1px dashed rgba(255,184,0,0.15);padding:30px;color:var(--text-dim)">
1984
+ <div style="font-size:22px;margin-bottom:10px;opacity:0.4">💬</div>
1985
+ Sin respuestas aún
1986
+ </div>`;
1987
+ return;
1988
+ }
1989
+ container.innerHTML = replies.map(r => {
1990
+ const dt = r.received_at ? r.received_at.replace('T', ' ').slice(0, 19) : '';
1991
+ return `
1992
+ <div class="reply-item">
1993
+ <div class="reply-header">
1994
+ <span class="reply-phone">+${escHtml(r.from_phone)}</span>
1995
+ <span class="reply-time">${escHtml(dt)}</span>
1996
+ </div>
1997
+ <div class="reply-body">${escHtml(r.body)}</div>
1998
+ </div>`;
1999
+ }).join('');
2000
+ }
2001
+
2002
+ function renderABVariants(variants) {
2003
+ const tab = document.getElementById('trackTabAB');
2004
+ const container = document.getElementById('trackABVariants');
2005
+
2006
+ if (!variants.length) {
2007
+ tab.style.display = 'none';
2008
+ container.innerHTML = `<div class="empty-state" style="border:none;padding:20px;color:var(--text-dim)">Sin variantes de imagen</div>`;
2009
+ return;
2010
+ }
2011
+
2012
+ tab.style.display = '';
2013
+ const maxTotal = Math.max(...variants.map(v => v.total || 0), 1);
2014
+
2015
+ container.innerHTML = variants.map(v => {
2016
+ const total = v.total || 0;
2017
+ const sent = v.sent || 0;
2018
+ const delivered = v.delivered || 0;
2019
+ const read = v.read || 0;
2020
+ const pct = n => total > 0 ? Math.round((n / total) * 100) + '%' : '—';
2021
+ const barW = Math.round((total / maxTotal) * 100);
2022
+ return `
2023
+ <div style="margin-bottom:16px;padding:12px;border:1px solid rgba(0,255,136,0.15);border-radius:4px">
2024
+ <div style="display:flex;align-items:center;gap:8px;margin-bottom:8px">
2025
+ <span style="background:var(--green);color:#000;font-weight:700;font-size:11px;padding:2px 8px;border-radius:2px">VAR ${escHtml(v.label)}</span>
2026
+ <span style="color:var(--text-dim);font-size:11px">${escHtml(v.original_name || '')}</span>
2027
+ <span style="margin-left:auto;font-size:11px;color:var(--text-dim)">${total} contactos</span>
2028
+ </div>
2029
+ <div style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:8px;margin-bottom:8px">
2030
+ <div style="text-align:center"><div style="font-size:18px;font-weight:700">${pct(sent)}</div><div style="font-size:10px;color:var(--text-dim)">ENVIADOS</div></div>
2031
+ <div style="text-align:center"><div style="font-size:18px;font-weight:700;color:var(--cyan)">${pct(delivered)}</div><div style="font-size:10px;color:var(--text-dim)">ENTREGADOS</div></div>
2032
+ <div style="text-align:center"><div style="font-size:18px;font-weight:700;color:#9f6eff">${pct(read)}</div><div style="font-size:10px;color:var(--text-dim)">LEÍDOS</div></div>
2033
+ </div>
2034
+ <div style="height:4px;background:rgba(255,255,255,0.08);border-radius:2px">
2035
+ <div style="height:100%;width:${barW}%;background:var(--green);border-radius:2px"></div>
2036
+ </div>
2037
+ </div>`;
2038
+ }).join('');
2039
+ }
2040
+
2041
+ // ── IMAGES MODAL ──
2042
+ let activeImagesCampaignId = null;
2043
+
2044
+ async function openImagesModal(campaignId) {
2045
+ activeImagesCampaignId = campaignId;
2046
+ activeImageCampaignId = campaignId;
2047
+ document.getElementById('imagesModal').classList.add('visible');
2048
+ await refreshImagesList();
2049
+ }
2050
+
2051
+ function closeImagesModal() {
2052
+ document.getElementById('imagesModal').classList.remove('visible');
2053
+ activeImagesCampaignId = null;
2054
+ activeImageCampaignId = null;
2055
+ }
2056
+
2057
+ async function refreshImagesList() {
2058
+ if (!activeImagesCampaignId) return;
2059
+ try {
2060
+ const images = await api('GET', `/api/campaigns/${activeImagesCampaignId}/images`);
2061
+ const container = document.getElementById('imagesList');
2062
+ const addBtn = document.getElementById('addImageBtn');
2063
+ addBtn.style.display = images.length >= 5 ? 'none' : '';
2064
+
2065
+ if (!images.length) {
2066
+ container.innerHTML = `<div style="color:var(--text-dim);font-size:12px;padding:12px 0">Sin imágenes. Agrega hasta 5 variantes para A/B testing.</div>`;
2067
+ return;
2068
+ }
2069
+
2070
+ container.innerHTML = images.map(img => `
2071
+ <div style="display:flex;align-items:center;gap:8px;padding:8px 0;border-bottom:1px solid rgba(255,255,255,0.06)">
2072
+ <span style="background:var(--green);color:#000;font-weight:700;font-size:11px;padding:2px 8px;border-radius:2px;flex-shrink:0">VAR ${escHtml(img.label)}</span>
2073
+ <span style="flex:1;font-size:12px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${escHtml(img.original_name || img.file_path)}</span>
2074
+ <button class="btn btn-red btn-sm" style="padding:2px 8px;font-size:10px" onclick="deleteImage(${activeImagesCampaignId}, ${img.id})">✕</button>
2075
+ </div>
2076
+ `).join('');
2077
+ } catch (e) {
2078
+ toast(e.message, 'error');
2079
+ }
2080
+ }
2081
+
2082
+ async function deleteImage(campaignId, imageId) {
2083
+ try {
2084
+ await api('DELETE', `/api/campaigns/${campaignId}/images/${imageId}`);
2085
+ toast('Imagen eliminada', 'success');
2086
+ await refreshImagesList();
2087
+ await loadCampaigns();
2088
+ } catch (e) {
2089
+ toast(e.message, 'error');
2090
+ }
2091
+ }
2092
+
2093
+ function triggerImageUpload() {
2094
+ document.getElementById('imageFileInput').value = '';
2095
+ document.getElementById('imageFileInput').click();
2096
+ }
2097
+
2098
+ async function handleImageUpload(event) {
2099
+ const file = event.target.files[0];
2100
+ if (!file || !activeImageCampaignId) return;
2101
+
2102
+ const formData = new FormData();
2103
+ formData.append('image', file);
2104
+
2105
+ toast(`Subiendo imagen "${file.name}"...`, 'info');
2106
+
2107
+ try {
2108
+ const result = await fetch(`${API}/api/campaigns/${activeImageCampaignId}/images`, {
2109
+ method: 'POST',
2110
+ body: formData,
2111
+ });
2112
+ const data = await result.json();
2113
+ if (!result.ok) throw new Error(data.error || 'Error al subir imagen');
2114
+ toast(`Imagen ${data.label} agregada`, 'success');
2115
+ await refreshImagesList();
2116
+ await loadCampaigns();
2117
+ } catch (e) {
2118
+ toast(e.message, 'error');
2119
+ }
2120
+ }
2121
+
2122
+ // ── DAILY LIMIT ──
2123
+ async function updateDailyLimit(accountId) {
2124
+ const val = parseInt(document.getElementById(`dailyLimit_${accountId}`).value, 10);
2125
+ if (isNaN(val) || val < 1) { toast('El límite debe ser >= 1', 'error'); return; }
2126
+ try {
2127
+ await api('PATCH', `/api/accounts/${accountId}`, { daily_limit: val });
2128
+ toast(`Límite diario de "${accountId}" actualizado a ${val}`, 'success');
2129
+ } catch (e) {
2130
+ toast(e.message, 'error');
2131
+ }
2132
+ }
2133
+
2134
+ // ── RESET ACCOUNT ──
2135
+ async function resetAccount(id) {
2136
+ if (!confirm(`¿Resetear la cuenta "${id}"?\nEsto borrará la sesión guardada y generará un QR nuevo.`)) return;
2137
+ try {
2138
+ await api('POST', `/api/accounts/${id}/reset`);
2139
+ toast(`Cuenta "${id}" reiniciada — escanea el nuevo QR`, 'info');
2140
+ await loadAccounts();
2141
+ openQrModal(id);
2142
+ } catch (e) {
2143
+ toast(e.message, 'error');
2144
+ }
2145
+ }
2146
+
2147
+ // ── INBOUND ──
2148
+ function replyModeLabel(mode) {
2149
+ return mode === 'new_contacts_only' ? '🆕 solo nuevos'
2150
+ : mode === 'always' ? '🔁 siempre'
2151
+ : '1️⃣ una vez';
2152
+ }
2153
+
2154
+ async function loadInbound() {
2155
+ try {
2156
+ inboundCampaigns = await api('GET', '/api/inbound');
2157
+ renderInbound();
2158
+ inboundCampaigns.forEach(c => {
2159
+ loadCouponBar(c.id);
2160
+ loadContactsBar(c.id);
2161
+ });
2162
+ } catch (e) {
2163
+ console.error('Error cargando campañas inbound:', e);
2164
+ }
2165
+ }
2166
+
2167
+ async function loadContactsBar(campaignId) {
2168
+ const bar = document.getElementById(`contacts-bar-${campaignId}`);
2169
+ if (!bar) return;
2170
+ try {
2171
+ const data = await api('GET', `/api/inbound/${campaignId}/contacts`);
2172
+ if (data.total === 0) {
2173
+ bar.innerHTML = `<span style="opacity:.5">CONTACTOS</span><span style="color:var(--text-dim);font-size:10px">sin contactos aún</span>`;
2174
+ } else {
2175
+ bar.innerHTML = `
2176
+ <span style="opacity:.5">CONTACTOS</span>
2177
+ <span class="coupon-pill">${data.total} recolectados</span>`;
2178
+ }
2179
+ } catch {
2180
+ bar.innerHTML = '';
2181
+ }
2182
+ }
2183
+
2184
+ async function loadCouponBar(campaignId) {
2185
+ const bar = document.getElementById(`coupon-bar-${campaignId}`);
2186
+ if (!bar) return;
2187
+ try {
2188
+ const data = await api('GET', `/api/inbound/${campaignId}/coupons`);
2189
+ const s = data.stats;
2190
+ if (s.total === 0) {
2191
+ bar.innerHTML = `<span style="opacity:.5">CUPONES</span><span style="color:var(--text-dim);font-size:10px">sin cupones — usa {{cupon}} en la plantilla</span>`;
2192
+ } else {
2193
+ const pillClass = s.available === 0 ? 'empty' : '';
2194
+ bar.innerHTML = `
2195
+ <span style="opacity:.5">CUPONES</span>
2196
+ <span class="coupon-pill ${pillClass}">${s.available} disponibles</span>
2197
+ <span style="color:var(--text-dim)">${s.used} usados · ${s.total} total</span>`;
2198
+ }
2199
+ } catch {
2200
+ bar.innerHTML = '';
2201
+ }
2202
+ }
2203
+
2204
+ function renderInbound() {
2205
+ const active = inboundCampaigns.filter(c => c.status === 'active').length;
2206
+ document.getElementById('inboundCount').textContent = `${active} activas`;
2207
+
2208
+ const list = document.getElementById('inboundList');
2209
+ if (!inboundCampaigns.length) {
2210
+ list.innerHTML = `<div class="empty-state"><div class="empty-icon">📥</div>Sin campañas inbound.<br>Crea una para responder automáticamente.</div>`;
2211
+ return;
2212
+ }
2213
+
2214
+ list.innerHTML = inboundCampaigns.map(c => {
2215
+ const unlimited = c.max_uses === 0;
2216
+ const pct = unlimited ? 0 : Math.min(100, Math.round((c.used_count / c.max_uses) * 100));
2217
+ const progressLabel = unlimited
2218
+ ? `${c.used_count} enviados (sin límite)`
2219
+ : `${c.used_count} / ${c.max_uses} usos (${pct}%)`;
2220
+ const barClass = c.status === 'exhausted' ? 'exhausted' : c.status === 'paused' ? 'paused' : '';
2221
+ const barWidth = unlimited ? 0 : pct;
2222
+
2223
+ const statusBadge = c.status === 'active'
2224
+ ? `<span class="badge badge-green"><span class="badge-dot"></span>ACTIVE</span>`
2225
+ : c.status === 'paused'
2226
+ ? `<span class="badge badge-amber"><span class="badge-dot"></span>PAUSED</span>`
2227
+ : `<span class="badge badge-red"><span class="badge-dot"></span>EXHAUSTED</span>`;
2228
+
2229
+ const pauseBtn = c.status === 'active'
2230
+ ? `<button class="btn btn-amber btn-sm" onclick="inboundAction(${c.id},'pause')">PAUSE</button>`
2231
+ : `<button class="btn btn-green btn-sm" onclick="inboundAction(${c.id},'activate')">ACTIVAR</button>`;
2232
+
2233
+ const couponHtml = `<div class="coupon-bar" id="coupon-bar-${c.id}">
2234
+ <span style="opacity:.5">CUPONES</span>
2235
+ <span class="loading-dots" style="font-size:10px;color:var(--text-dim)">cargando</span>
2236
+ </div>`;
2237
+
2238
+ const contactsHtml = `<div class="coupon-bar" id="contacts-bar-${c.id}">
2239
+ <span style="opacity:.5">CONTACTOS</span>
2240
+ <span class="loading-dots" style="font-size:10px;color:var(--text-dim)">cargando</span>
2241
+ </div>`;
2242
+
2243
+ return `
2244
+ <div class="inbound-card status-${c.status}">
2245
+ <div class="inbound-header">
2246
+ <div class="inbound-meta">
2247
+ <div class="inbound-name">${escHtml(c.name)}</div>
2248
+ <div class="inbound-account">cuenta: ${escHtml(c.account_id)} &nbsp;·&nbsp; ${replyModeLabel(c.reply_mode)}</div>
2249
+ </div>
2250
+ ${statusBadge}
2251
+ </div>
2252
+ <div class="inbound-template">${escHtml(c.template)}</div>
2253
+ ${couponHtml}
2254
+ ${contactsHtml}
2255
+ <div class="inbound-progress">
2256
+ <div class="inbound-progress-bar-wrap">
2257
+ <div class="inbound-progress-bar ${barClass}" style="width:${barWidth}%"></div>
2258
+ </div>
2259
+ <div class="inbound-progress-label">${progressLabel}</div>
2260
+ </div>
2261
+ <div class="inbound-actions">
2262
+ ${pauseBtn}
2263
+ <button class="btn btn-sm" style="border-color:rgba(0,229,255,0.4);color:#00e5ff" onclick="openCouponUpload(${c.id})">+ CUPONES</button>
2264
+ <button class="btn btn-sm" style="border-color:rgba(0,255,136,0.3);color:var(--green-dim)" onclick="exportInboundContacts(${c.id})">EXPORTAR CSV</button>
2265
+ <button class="btn btn-sm" style="border-color:rgba(0,255,136,0.5);color:var(--green)" onclick="openToOutboundModal(${c.id}, '${escHtml(c.name)}')">→ OUTBOUND</button>
2266
+ <button class="btn btn-red btn-sm" onclick="inboundDelete(${c.id})">DEL</button>
2267
+ </div>
2268
+ </div>`;
2269
+ }).join('');
2270
+ }
2271
+
2272
+ function openNewInboundModal() {
2273
+ // Poblar select de cuentas con las que están ready
2274
+ const sel = document.getElementById('inboundAccount');
2275
+ sel.innerHTML = '<option value="">— seleccionar cuenta —</option>' +
2276
+ accounts.filter(a => a.status === 'ready').map(a =>
2277
+ `<option value="${escHtml(a.id)}">${escHtml(a.id)}${a.phone ? ' (+' + a.phone + ')' : ''}</option>`
2278
+ ).join('');
2279
+ document.getElementById('inboundName').value = '';
2280
+ document.getElementById('inboundTemplate').value = '';
2281
+ document.getElementById('inboundReplyMode').value = 'once_per_campaign';
2282
+ document.getElementById('inboundMaxUses').value = '0';
2283
+ document.getElementById('inboundModal').classList.add('visible');
2284
+ }
2285
+
2286
+ function closeInboundModal() {
2287
+ document.getElementById('inboundModal').classList.remove('visible');
2288
+ }
2289
+
2290
+ async function createInboundCampaign() {
2291
+ const account_id = document.getElementById('inboundAccount').value;
2292
+ const name = document.getElementById('inboundName').value.trim();
2293
+ const template = document.getElementById('inboundTemplate').value.trim();
2294
+ const reply_mode = document.getElementById('inboundReplyMode').value;
2295
+ const max_uses = parseInt(document.getElementById('inboundMaxUses').value, 10) || 0;
2296
+
2297
+ if (!account_id) { toast('Selecciona una cuenta', 'error'); return; }
2298
+ if (!name) { toast('Ingresa un nombre', 'error'); return; }
2299
+ if (!template) { toast('Ingresa el mensaje', 'error'); return; }
2300
+
2301
+ try {
2302
+ await api('POST', '/api/inbound', { account_id, name, template, reply_mode, max_uses });
2303
+ closeInboundModal();
2304
+ toast('Campaña inbound creada', 'success');
2305
+ await loadInbound();
2306
+ } catch (e) {
2307
+ toast(e.message, 'error');
2308
+ }
2309
+ }
2310
+
2311
+ // ── EXPORT CONTACTS CSV ──
2312
+ async function exportInboundContacts(campaignId) {
2313
+ try {
2314
+ const res = await fetch(`${API}/api/inbound/${campaignId}/contacts/export`);
2315
+ if (!res.ok) {
2316
+ const d = await res.json().catch(() => ({}));
2317
+ throw new Error(d.error || `Error ${res.status}`);
2318
+ }
2319
+ const blob = await res.blob();
2320
+ const disposition = res.headers.get('Content-Disposition') || '';
2321
+ const match = disposition.match(/filename="([^"]+)"/);
2322
+ const filename = match ? match[1] : `inbound_${campaignId}_contacts.csv`;
2323
+ const url = URL.createObjectURL(blob);
2324
+ const a = document.createElement('a');
2325
+ a.href = url; a.download = filename; a.click();
2326
+ URL.revokeObjectURL(url);
2327
+ toast('CSV descargado', 'success');
2328
+ } catch (e) {
2329
+ toast(e.message, 'error');
2330
+ }
2331
+ }
2332
+
2333
+ // ── → OUTBOUND MODAL ──
2334
+ let activeToOutboundCampaignId = null;
2335
+
2336
+ async function openToOutboundModal(campaignId, campaignName) {
2337
+ activeToOutboundCampaignId = campaignId;
2338
+
2339
+ document.getElementById('toOutboundName').value = `Outbound — ${campaignName}`;
2340
+ document.getElementById('toOutboundTemplate').value = '';
2341
+ document.getElementById('toOutboundDelay').value = '3000';
2342
+
2343
+ // Poblar cuentas ready
2344
+ const accList = document.getElementById('toOutboundAccountsList');
2345
+ const readyAccounts = accounts.filter(a => a.status === 'ready');
2346
+ if (readyAccounts.length === 0) {
2347
+ accList.innerHTML = `<div class="no-accounts-msg">No hay cuentas READY disponibles</div>`;
2348
+ } else {
2349
+ accList.innerHTML = readyAccounts.map(a => `
2350
+ <label class="account-checkbox-item">
2351
+ <input type="checkbox" value="${escHtml(a.id)}" checked>
2352
+ <div class="account-checkbox-info">
2353
+ <span class="account-checkbox-id">${escHtml(a.id)}</span>
2354
+ <span class="account-checkbox-phone">${a.phone ? '+' + a.phone : '—'}</span>
2355
+ </div>
2356
+ </label>`).join('');
2357
+ }
2358
+
2359
+ // Mostrar cuántos contactos se importarán
2360
+ const infoEl = document.getElementById('toOutboundContactsInfo');
2361
+ infoEl.textContent = 'Cargando contactos...';
2362
+ try {
2363
+ const data = await api('GET', `/api/inbound/${campaignId}/contacts`);
2364
+ infoEl.textContent = `✓ Se importarán ${data.total} contacto${data.total !== 1 ? 's' : ''}`;
2365
+ } catch {
2366
+ infoEl.textContent = '';
2367
+ }
2368
+
2369
+ document.getElementById('inboundToOutboundModal').classList.add('visible');
2370
+ }
2371
+
2372
+ function closeToOutboundModal() {
2373
+ document.getElementById('inboundToOutboundModal').classList.remove('visible');
2374
+ activeToOutboundCampaignId = null;
2375
+ }
2376
+
2377
+ async function submitToOutbound() {
2378
+ const name = document.getElementById('toOutboundName').value.trim();
2379
+ const template = document.getElementById('toOutboundTemplate').value.trim();
2380
+ const delay_ms = parseInt(document.getElementById('toOutboundDelay').value, 10) || 3000;
2381
+ const account_ids = Array.from(
2382
+ document.querySelectorAll('#toOutboundAccountsList input[type=checkbox]:checked')
2383
+ ).map(cb => cb.value);
2384
+
2385
+ if (!name) { toast('Ingresa un nombre', 'error'); return; }
2386
+ if (!template) { toast('Ingresa el template', 'error'); return; }
2387
+ if (account_ids.length === 0) { toast('Selecciona al menos una cuenta', 'error'); return; }
2388
+
2389
+ try {
2390
+ const data = await api('POST', `/api/inbound/${activeToOutboundCampaignId}/to-campaign`, {
2391
+ name, template, account_ids, delay_ms,
2392
+ });
2393
+ closeToOutboundModal();
2394
+ toast(`Campaña creada con ${data.contacts_count} contactos`, 'success');
2395
+ await loadCampaigns();
2396
+ } catch (e) {
2397
+ toast(e.message, 'error');
2398
+ }
2399
+ }
2400
+
2401
+ function openCouponUpload(campaignId) {
2402
+ activeCouponCampaignId = campaignId;
2403
+ document.getElementById('couponFileInput').value = '';
2404
+ document.getElementById('couponFileInput').click();
2405
+ }
2406
+
2407
+ async function handleCouponUpload(event) {
2408
+ const file = event.target.files[0];
2409
+ if (!file || !activeCouponCampaignId) return;
2410
+
2411
+ const formData = new FormData();
2412
+ formData.append('file', file);
2413
+
2414
+ try {
2415
+ const res = await fetch(`${API}/api/inbound/${activeCouponCampaignId}/coupons`, {
2416
+ method: 'POST',
2417
+ body: formData,
2418
+ });
2419
+ const data = await res.json().catch(() => ({}));
2420
+ if (!res.ok) throw new Error(data.error || `Error ${res.status}`);
2421
+ toast(`${data.added} cupones cargados`, 'success');
2422
+ loadCouponBar(activeCouponCampaignId);
2423
+ } catch (e) {
2424
+ toast(e.message, 'error');
2425
+ }
2426
+ activeCouponCampaignId = null;
2427
+ }
2428
+
2429
+ async function inboundAction(id, action) {
2430
+ try {
2431
+ await api('POST', `/api/inbound/${id}/${action}`);
2432
+ toast(`Campaña inbound ${action === 'pause' ? 'pausada' : 'activada'}`, 'success');
2433
+ await loadInbound();
2434
+ } catch (e) {
2435
+ toast(e.message, 'error');
2436
+ }
2437
+ }
2438
+
2439
+ async function inboundDelete(id) {
2440
+ if (!confirm(`¿Eliminar campaña inbound #${id}?`)) return;
2441
+ try {
2442
+ await api('DELETE', `/api/inbound/${id}`);
2443
+ toast('Campaña inbound eliminada', 'success');
2444
+ await loadInbound();
2445
+ } catch (e) {
2446
+ toast(e.message, 'error');
2447
+ }
2448
+ }
2449
+
2450
+ // ── INIT ──
2451
+ async function init() {
2452
+ await Promise.all([loadAccounts(), loadCampaigns(), loadInbound()]);
2453
+ schedulePoll();
2454
+ }
2455
+
2456
+ init();
2457
+ </script>
2458
+ </body>
2459
+ </html>