Jose Salazar commited on
Commit
8e14ac4
·
1 Parent(s): de11b49

Cambios en js de UI para evitar vulnerabilidades a XSS de innerHTML

Browse files
frontend/dist/assets/index-A_KP_t7E.js DELETED
The diff for this file is too large to render. See raw diff
 
frontend/dist/assets/index-DUFVKRJi.js ADDED
The diff for this file is too large to render. See raw diff
 
frontend/dist/index.html CHANGED
@@ -5,7 +5,7 @@
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
  <title>PolySignal — Dashboard de Inteligencia de Mercados</title>
7
  <link rel="icon" type="image/svg+xml" href="/vite.svg" />
8
- <script type="module" crossorigin src="/assets/index-A_KP_t7E.js"></script>
9
  <link rel="stylesheet" crossorigin href="/assets/index-xtYPhhTl.css">
10
  </head>
11
  <body>
 
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
  <title>PolySignal — Dashboard de Inteligencia de Mercados</title>
7
  <link rel="icon" type="image/svg+xml" href="/vite.svg" />
8
+ <script type="module" crossorigin src="/assets/index-DUFVKRJi.js"></script>
9
  <link rel="stylesheet" crossorigin href="/assets/index-xtYPhhTl.css">
10
  </head>
11
  <body>
frontend/src/app.js CHANGED
@@ -224,11 +224,23 @@ function translateSignal(signal) {
224
  return 'neutral'
225
  }
226
 
 
 
 
 
 
 
 
 
 
 
 
 
227
  /* ─── Routing de vistas ─── */
228
  function switchView(viewName) {
229
  state.view = viewName
230
- document.querySelectorAll('.view').forEach((el) => el.classList.toggle('active', el.id === `view-${viewName}`))
231
- document.querySelectorAll('.nav-item').forEach((el) => el.classList.toggle('active', el.dataset.view === viewName))
232
  if (viewName === 'positions') renderPositions()
233
  if (viewName === 'watchlist') renderWatchlist()
234
  if (viewName === 'alerts') renderAlerts()
@@ -253,32 +265,40 @@ function togglePanel(panelId) {
253
  function renderSignals() {
254
  const container = document.getElementById('signals-list')
255
  if (!container) return
256
- container.innerHTML = state.markets
257
- .map((m) => {
258
- const sig = state.signals.find((s) => s.marketId === m.id) || MOCK_SIGNALS[m.id] || { signal: 'neutral', confidence: 0.5 }
259
- const isActive = state.activeMarketId === m.id
260
- const cls = signalColorClass(sig.signal)
261
- const badgeClass = getSignalBadgeClass(sig.signal)
262
- return `
263
- <div class="market-card ${isActive ? 'active' : ''}" data-market="${m.id}">
264
- <div class="market-cat">${m.category || 'General'} · ${m.countryCode || 'GL'}</div>
265
- <div class="market-q">${m.question}</div>
266
- <div class="market-footer">
267
- <div class="prob-bar-wrap">
268
- <div class="prob-bar-bg">
269
- <div class="prob-bar-fill bg-${cls}" style="--prob-width:${Math.round(m.yesPrice * 100)}%"></div>
270
- </div>
271
- </div>
272
- <span class="prob-val text-${cls}">${formatPrice(m.yesPrice)}</span>
273
- <span class="signal-badge ${badgeClass}">${getSignalLabel(sig.signal)}</span>
274
- </div>
275
- </div>
276
- `
277
- })
278
- .join('')
279
 
280
- container.querySelectorAll('.market-card').forEach((card) => {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
281
  card.addEventListener('click', () => selectMarket(card.dataset.market))
 
282
  })
283
  }
284
 
@@ -287,35 +307,35 @@ function renderMiniPositions() {
287
  const container = document.getElementById('mini-positions')
288
  if (!container) return
289
  if (state.positions.length === 0) {
290
- container.innerHTML = '<div class="empty-state empty-state-sm">Aún sin posiciones</div>'
291
  return
292
  }
 
 
293
  let netPnl = 0
294
- const items = state.positions
295
- .map((p) => {
296
- const m = state.markets.find((x) => x.id === p.marketId) || { question: p.marketId }
297
- netPnl += p.pnl
298
- const cls = p.pnl >= 0 ? 'green' : 'red'
299
- const sign = p.pnl >= 0 ? '+' : ''
300
- return `
301
- <div class="flex-between mb-6">
302
- <span class="text-sm text-neutral font-mono">${m.question.substring(0, 32)}${m.question.length > 32 ? '…' : ''} ${p.outcome}</span>
303
- <span class="text-base font-semibold text-${cls} font-mono">${sign}€${p.pnl.toFixed(2)}</span>
304
- </div>
305
- `
306
- })
307
- .join('')
308
 
309
  const netCls = netPnl >= 0 ? 'green' : 'red'
310
  const netSign = netPnl >= 0 ? '+' : ''
311
- container.innerHTML = `
312
- ${items}
313
- <div class="divider"></div>
314
- <div class="flex-between">
315
- <span class="text-sm text-neutral font-mono">G&amp;P Neto</span>
316
- <span class="text-lg font-bold text-${netCls} font-mono">${netSign}€${netPnl.toFixed(2)}</span>
317
- </div>
318
- `
319
  }
320
 
321
  /* ─── Render detail panel ─── */
@@ -324,7 +344,7 @@ function renderDetail() {
324
  if (!container) return
325
  const m = state.markets.find((x) => x.id === state.activeMarketId)
326
  if (!m) {
327
- container.innerHTML = '<div class="empty-state">Selecciona un mercado para ver detalles</div>'
328
  return
329
  }
330
 
@@ -333,71 +353,109 @@ function renderDetail() {
333
  const deltaCls = m.yesPrice > 0.5 ? 'green' : 'red'
334
  const deltaSign = m.yesPrice > 0.5 ? '+' : ''
335
 
336
- container.innerHTML = `
337
- <div class="detail-header">
338
- <div>
339
- <div class="detail-tag">${m.countryCode || 'GL'} · ${m.category || 'General'} · Polymarket</div>
340
- <div class="detail-q">${m.question}</div>
341
- <div class="detail-meta">Vol: ${formatCurrency(m.volumeEur || 0)} · Liq: ${formatCurrency(m.liquidityEur || 0)} · Cierra: ${formatDate(m.closesAt)}</div>
342
- </div>
343
- <div class="detail-metrics">
344
- <div class="metric">
345
- <div class="metric-label">Cambio 24h</div>
346
- <div class="metric-value text-${deltaCls}">${deltaSign}${delta}%</div>
347
- </div>
348
- <div class="metric-sep"></div>
349
- <div class="metric">
350
- <div class="metric-label">Confianza</div>
351
- <div class="metric-value text-blue">${Math.round(sig.confidence * 100)}%</div>
352
- </div>
353
- </div>
354
- </div>
355
-
356
- <div class="outcomes-row">
357
- <div class="outcome-card yes">
358
- <div class="outcome-name">SÍ</div>
359
- <div class="outcome-price">${formatPrice(m.yesPrice)}</div>
360
- <div class="outcome-delta td-green">▲ ${(m.yesPrice * 0.05).toFixed(1)}¢</div>
361
- <div class="sparkline" id="spark-yes"></div>
362
- </div>
363
- <div class="outcome-card no">
364
- <div class="outcome-name">NO</div>
365
- <div class="outcome-price">${formatPrice(m.noPrice)}</div>
366
- <div class="outcome-delta td-red">▼ ${(m.noPrice * 0.05).toFixed(1)}¢</div>
367
- <div class="sparkline" id="spark-no"></div>
368
- </div>
369
- <div class="chart-container">
370
- <div class="chart-label">Historial de precios 7d</div>
371
- <canvas id="detail-chart"></canvas>
372
- </div>
373
- </div>
374
-
375
- <div class="ai-box">
376
- <div class="ai-icon">◈</div>
377
- <div class="flex-1">
378
- <div class="flex-row gap-8 mb-4 flex-wrap">
379
- <div class="ai-label">Análisis IA · HuggingFace Qwen3-8B</div>
380
- <span class="signal-badge ${getSignalBadgeClass(sig.signal)}">${translateSignal(sig.signal).toUpperCase()} · ${Math.round(sig.confidence * 100)}%</span>
381
- <span class="text-xs text-neutral font-mono ml-auto">actualizado hace 2m</span>
382
- </div>
383
- <div class="ai-text">${sig.summary}${sig.keyRisk ? ' <strong>Riesgo clave:</strong> ' + sig.keyRisk : ''}</div>
384
- </div>
385
- </div>
386
-
387
- <div class="sim-row">
388
- <span class="sim-label">Simular posición →</span>
389
- <input class="sim-input" type="number" id="sim-amount" value="100" min="1" placeholder="€"/>
390
- <button class="sim-btn-yes" id="sim-yes">COMPRAR ↗</button>
391
- <button class="sim-btn-no" id="sim-no">COMPRAR NO</button>
392
- <span class="sim-disclaimer">Simulado · sin trading real</span>
393
- </div>
394
- `
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
395
 
396
  // Bind simulator buttons
397
- document.getElementById('sim-yes')?.addEventListener('click', () => simulator.openPosition(m.id, 'SÍ', document.getElementById('sim-amount').value))
398
- document.getElementById('sim-no')?.addEventListener('click', () => simulator.openPosition(m.id, 'NO', document.getElementById('sim-amount').value))
399
 
400
- // Render chart
401
  charts.renderDetailChart('detail-chart', m.yesPrice)
402
  charts.renderSparkline('spark-yes', m.yesPrice, 'yes')
403
  charts.renderSparkline('spark-no', m.noPrice, 'no')
@@ -417,34 +475,55 @@ function renderPositions() {
417
  const empty = document.getElementById('positions-empty')
418
  if (!tbody) return
419
  if (state.positions.length === 0) {
420
- tbody.innerHTML = ''
421
  empty.classList.remove('hidden')
422
  return
423
  }
424
  empty.classList.add('hidden')
425
- tbody.innerHTML = state.positions
426
- .map((p) => {
427
- const m = state.markets.find((x) => x.id === p.marketId) || { question: p.marketId }
428
- const pnlColor = p.pnl >= 0 ? 'td-green' : 'td-red'
429
- const sign = p.pnl >= 0 ? '+' : ''
430
- return `
431
- <tr>
432
- <td>${m.question.substring(0, 40)}${m.question.length > 40 ? '' : ''}</td>
433
- <td class="td-mono ${p.outcome === 'SÍ' ? 'td-green' : 'td-red'}">${p.outcome}</td>
434
- <td class="td-mono">€${p.amountEur.toFixed(0)}</td>
435
- <td class="td-mono">${formatPrice(p.entryPrice)}</td>
436
- <td class="td-mono">${formatPrice(p.currentPrice)}</td>
437
- <td class="td-mono ${pnlColor}">${sign}€${p.pnl.toFixed(2)}</td>
438
- <td class="td-mono td-blue">${((p.kellyFraction || 0) * 100).toFixed(0)}%</td>
439
- <td class="td-mono">${formatDate(p.openedAt)}</td>
440
- <td><button class="btn-ghost" onclick="closePositionById(${p.id})">Cerrar</button></td>
441
- </tr>
442
- `
443
- })
444
- .join('')
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
445
  }
446
 
447
- window.closePositionById = async (id) => {
448
  await simulator.closePosition(id)
449
  await loadPositions()
450
  renderPositions()
@@ -457,32 +536,53 @@ function renderWatchlist() {
457
  const empty = document.getElementById('watchlist-empty')
458
  if (!tbody) return
459
  if (state.watchlist.length === 0) {
460
- tbody.innerHTML = ''
461
  empty.classList.remove('hidden')
462
  return
463
  }
464
  empty.classList.add('hidden')
465
- tbody.innerHTML = state.watchlist
466
- .map((w) => {
467
- const m = state.markets.find((x) => x.id === w.marketId) || { question: w.marketId, category: '-', yesPrice: 0, noPrice: 0, volumeEur: 0 }
468
- const sig = state.signals.find((s) => s.marketId === w.marketId) || { signal: 'neutral' }
469
- return `
470
- <tr>
471
- <td>${m.question.substring(0, 40)}${m.question.length > 40 ? '' : ''}</td>
472
- <td>${m.category || '-'}</td>
473
- <td class="td-mono td-green">${formatPrice(m.yesPrice)}</td>
474
- <td class="td-mono td-red">${formatPrice(m.noPrice)}</td>
475
- <td><span class="signal-badge ${getSignalBadgeClass(sig.signal)}">${getSignalLabel(sig.signal)}</span></td>
476
- <td class="td-mono">${formatCurrency(m.volumeEur || 0)}</td>
477
- <td class="td-mono">${w.alertThreshold ? formatPrice(w.alertThreshold) : '-'}</td>
478
- <td><button class="btn-ghost" onclick="removeFromWatchlistById('${w.marketId}')">Eliminar</button></td>
479
- </tr>
480
- `
481
- })
482
- .join('')
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
483
  }
484
 
485
- window.removeFromWatchlistById = async (marketId) => {
486
  try { await api.removeFromWatchlist(marketId) } catch (e) { console.warn(e) }
487
  state.watchlist = state.watchlist.filter((w) => w.marketId !== marketId)
488
  renderWatchlist()
@@ -494,24 +594,35 @@ function renderAlerts() {
494
  const empty = document.getElementById('alerts-empty')
495
  if (!tbody) return
496
  if (state.alerts.length === 0) {
497
- tbody.innerHTML = ''
498
  empty.classList.remove('hidden')
499
  return
500
  }
501
  empty.classList.add('hidden')
502
- tbody.innerHTML = state.alerts
503
- .map((a) => {
504
- const m = state.markets.find((x) => x.id === a.marketId) || { question: a.marketId }
505
- return `
506
- <tr>
507
- <td class="td-mono">${new Date(a.sentAt).toLocaleString('es-ES')}</td>
508
- <td>${m.question.substring(0, 35)}${m.question.length > 35 ? '…' : ''}</td>
509
- <td><span class="signal-badge sig-neut">${a.type}</span></td>
510
- <td>${a.message}</td>
511
- </tr>
512
- `
513
- })
514
- .join('')
 
 
 
 
 
 
 
 
 
 
 
515
  }
516
 
517
  /* ─── Carga de datos ─── */
@@ -562,48 +673,44 @@ async function loadAlerts() {
562
 
563
  /* ─── Inicialización ─── */
564
  export async function init() {
565
- // Sidebar toggle
566
  document.getElementById('sidebar-toggle')?.addEventListener('click', toggleSidebar)
567
 
568
- // Nav routing
569
- document.querySelectorAll('.nav-item').forEach((el) => {
570
- el.addEventListener('click', () => switchView(el.dataset.view))
571
  })
572
 
573
- // Panel toggles
574
- document.querySelectorAll('.panel-header[data-panel]').forEach((el) => {
575
- el.addEventListener('click', (e) => {
576
- // Evitar colapsar al clicar elementos interactivos
577
  if (e.target.closest('button, input, a')) return
578
- togglePanel(el.dataset.panel)
579
  })
580
  })
581
 
582
- // Load initial data
583
  await loadMarkets()
584
  await loadSignals()
585
  await loadPositions()
586
  await loadWatchlist()
587
  await loadAlerts()
588
 
589
- // Init modules
590
  map.init('map-container', state.markets, state.signals, selectMarket)
591
  simulator.init(state)
592
 
593
- // Initial render
594
  state.activeMarketId = state.markets[0]?.id || null
595
  renderSignals()
596
  renderDetail()
597
  renderMiniPositions()
598
 
599
- // Socket.io
600
  const socket = io()
601
  socket.on('connect', () => console.log('Socket.io conectado'))
602
 
603
  socket.on('market_update', (data) => {
604
  const m = state.markets.find((x) => x.id === data.marketId)
605
  if (m) {
606
- Object.assign(m, data)
 
 
 
 
607
  if (state.activeMarketId === data.marketId) renderDetail()
608
  renderSignals()
609
  map.updateBubble(data.marketId, data.yesPrice)
@@ -611,6 +718,7 @@ export async function init() {
611
  })
612
 
613
  socket.on('ai_signal', (data) => {
 
614
  const idx = state.signals.findIndex((s) => s.marketId === data.marketId)
615
  if (idx >= 0) state.signals[idx] = data
616
  else state.signals.push(data)
@@ -619,21 +727,20 @@ export async function init() {
619
  })
620
 
621
  socket.on('price_alert', (data) => {
 
622
  state.alerts.unshift(data)
623
  if (state.view === 'alerts') renderAlerts()
624
  })
625
 
626
- // Stats animation mock
627
  setInterval(() => {
628
- const el = document.getElementById('stat-markets')
629
- if (el) {
630
- const n = parseInt(el.textContent.replace(/\./g, '')) + Math.floor(Math.random() * 3)
631
- el.textContent = n.toLocaleString('es-ES')
632
  }
633
- const el2 = document.getElementById('stat-signals')
634
- if (el2 && Math.random() > 0.7) {
635
- const n = parseInt(el2.textContent) + 1
636
- el2.textContent = n
637
  }
638
  }, 3000)
639
  }
 
224
  return 'neutral'
225
  }
226
 
227
+ // Creates an element with an optional class and optional text content
228
+ function el(tag, className, text) {
229
+ const node = document.createElement(tag)
230
+ if (className) node.className = className
231
+ if (text !== undefined) node.textContent = text
232
+ return node
233
+ }
234
+
235
+ function emptyState(text, sm = false) {
236
+ return el('div', sm ? 'empty-state empty-state-sm' : 'empty-state', text)
237
+ }
238
+
239
  /* ─── Routing de vistas ─── */
240
  function switchView(viewName) {
241
  state.view = viewName
242
+ document.querySelectorAll('.view').forEach((v) => v.classList.toggle('active', v.id === `view-${viewName}`))
243
+ document.querySelectorAll('.nav-item').forEach((v) => v.classList.toggle('active', v.dataset.view === viewName))
244
  if (viewName === 'positions') renderPositions()
245
  if (viewName === 'watchlist') renderWatchlist()
246
  if (viewName === 'alerts') renderAlerts()
 
265
  function renderSignals() {
266
  const container = document.getElementById('signals-list')
267
  if (!container) return
268
+ container.replaceChildren()
269
+
270
+ state.markets.forEach((m) => {
271
+ const sig = state.signals.find((s) => s.marketId === m.id) || MOCK_SIGNALS[m.id] || { signal: 'neutral', confidence: 0.5 }
272
+ const cls = signalColorClass(sig.signal)
273
+
274
+ const card = el('div', `market-card${state.activeMarketId === m.id ? ' active' : ''}`)
275
+ card.dataset.market = m.id
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
276
 
277
+ const cat = el('div', 'market-cat')
278
+ cat.textContent = `${m.category || 'General'} · ${m.countryCode || 'GL'}`
279
+
280
+ const q = el('div', 'market-q')
281
+ q.textContent = m.question
282
+
283
+ const footer = el('div', 'market-footer')
284
+
285
+ const probWrap = el('div', 'prob-bar-wrap')
286
+ const probBg = el('div', 'prob-bar-bg')
287
+ const probFill = el('div', `prob-bar-fill bg-${cls}`)
288
+ probFill.style.setProperty('--prob-width', `${Math.round(m.yesPrice * 100)}%`)
289
+ probBg.appendChild(probFill)
290
+ probWrap.appendChild(probBg)
291
+
292
+ const probVal = el('span', `prob-val text-${cls}`)
293
+ probVal.textContent = formatPrice(m.yesPrice)
294
+
295
+ const badge = el('span', `signal-badge ${getSignalBadgeClass(sig.signal)}`)
296
+ badge.textContent = getSignalLabel(sig.signal)
297
+
298
+ footer.append(probWrap, probVal, badge)
299
+ card.append(cat, q, footer)
300
  card.addEventListener('click', () => selectMarket(card.dataset.market))
301
+ container.appendChild(card)
302
  })
303
  }
304
 
 
307
  const container = document.getElementById('mini-positions')
308
  if (!container) return
309
  if (state.positions.length === 0) {
310
+ container.replaceChildren(emptyState('Aún sin posiciones', true))
311
  return
312
  }
313
+ container.replaceChildren()
314
+
315
  let netPnl = 0
316
+ state.positions.forEach((p) => {
317
+ const m = state.markets.find((x) => x.id === p.marketId) || { question: p.marketId }
318
+ netPnl += p.pnl
319
+ const cls = p.pnl >= 0 ? 'green' : 'red'
320
+ const sign = p.pnl >= 0 ? '+' : ''
321
+
322
+ const row = el('div', 'flex-between mb-6')
323
+ const label = el('span', 'text-sm text-neutral font-mono')
324
+ label.textContent = `${m.question.substring(0, 32)}${m.question.length > 32 ? '…' : ''} ${p.outcome}`
325
+ const val = el('span', `text-base font-semibold text-${cls} font-mono`)
326
+ val.textContent = `${sign}€${p.pnl.toFixed(2)}`
327
+ row.append(label, val)
328
+ container.appendChild(row)
329
+ })
330
 
331
  const netCls = netPnl >= 0 ? 'green' : 'red'
332
  const netSign = netPnl >= 0 ? '+' : ''
333
+ const netRow = el('div', 'flex-between')
334
+ const netLabel = el('span', 'text-sm text-neutral font-mono', 'G&P Neto')
335
+ const netVal = el('span', `text-lg font-bold text-${netCls} font-mono`)
336
+ netVal.textContent = `${netSign}€${netPnl.toFixed(2)}`
337
+ netRow.append(netLabel, netVal)
338
+ container.append(el('div', 'divider'), netRow)
 
 
339
  }
340
 
341
  /* ─── Render detail panel ─── */
 
344
  if (!container) return
345
  const m = state.markets.find((x) => x.id === state.activeMarketId)
346
  if (!m) {
347
+ container.replaceChildren(emptyState('Selecciona un mercado para ver detalles'))
348
  return
349
  }
350
 
 
353
  const deltaCls = m.yesPrice > 0.5 ? 'green' : 'red'
354
  const deltaSign = m.yesPrice > 0.5 ? '+' : ''
355
 
356
+ // ── Header ──
357
+ const tag = el('div', 'detail-tag')
358
+ tag.textContent = `${m.countryCode || 'GL'} · ${m.category || 'General'} · Polymarket`
359
+ const q = el('div', 'detail-q')
360
+ q.textContent = m.question
361
+ const meta = el('div', 'detail-meta')
362
+ meta.textContent = `Vol: ${formatCurrency(m.volumeEur || 0)} · Liq: ${formatCurrency(m.liquidityEur || 0)} · Cierra: ${formatDate(m.closesAt)}`
363
+ const headerLeft = el('div')
364
+ headerLeft.append(tag, q, meta)
365
+
366
+ const deltaEl = el('div', `metric-value text-${deltaCls}`)
367
+ deltaEl.textContent = `${deltaSign}${delta}%`
368
+ const metricDelta = el('div', 'metric')
369
+ metricDelta.append(el('div', 'metric-label', 'Cambio 24h'), deltaEl)
370
+
371
+ const confEl = el('div', 'metric-value text-blue')
372
+ confEl.textContent = `${Math.round(sig.confidence * 100)}%`
373
+ const metricConf = el('div', 'metric')
374
+ metricConf.append(el('div', 'metric-label', 'Confianza'), confEl)
375
+
376
+ const metrics = el('div', 'detail-metrics')
377
+ metrics.append(metricDelta, el('div', 'metric-sep'), metricConf)
378
+
379
+ const header = el('div', 'detail-header')
380
+ header.append(headerLeft, metrics)
381
+
382
+ // ── Outcomes row ──
383
+ const sparkYes = el('div', 'sparkline')
384
+ sparkYes.id = 'spark-yes'
385
+ const yesPriceEl = el('div', 'outcome-price')
386
+ yesPriceEl.textContent = formatPrice(m.yesPrice)
387
+ const yesDeltaEl = el('div', 'outcome-delta td-green')
388
+ yesDeltaEl.textContent = `▲ ${(m.yesPrice * 0.05).toFixed(1)}¢`
389
+ const yesCard = el('div', 'outcome-card yes')
390
+ yesCard.append(el('div', 'outcome-name', 'SÍ'), yesPriceEl, yesDeltaEl, sparkYes)
391
+
392
+ const sparkNo = el('div', 'sparkline')
393
+ sparkNo.id = 'spark-no'
394
+ const noPriceEl = el('div', 'outcome-price')
395
+ noPriceEl.textContent = formatPrice(m.noPrice)
396
+ const noDeltaEl = el('div', 'outcome-delta td-red')
397
+ noDeltaEl.textContent = `▼ ${(m.noPrice * 0.05).toFixed(1)}¢`
398
+ const noCard = el('div', 'outcome-card no')
399
+ noCard.append(el('div', 'outcome-name', 'NO'), noPriceEl, noDeltaEl, sparkNo)
400
+
401
+ const detailChart = el('canvas')
402
+ detailChart.id = 'detail-chart'
403
+ const chartContainer = el('div', 'chart-container')
404
+ chartContainer.append(el('div', 'chart-label', 'Historial de precios 7d'), detailChart)
405
+
406
+ const outcomesRow = el('div', 'outcomes-row')
407
+ outcomesRow.append(yesCard, noCard, chartContainer)
408
+
409
+ // ── AI box ──
410
+ const aiBadge = el('span', `signal-badge ${getSignalBadgeClass(sig.signal)}`)
411
+ aiBadge.textContent = `${translateSignal(sig.signal).toUpperCase()} · ${Math.round(sig.confidence * 100)}%`
412
+ const aiMeta = el('span', 'text-xs text-neutral font-mono ml-auto', 'actualizado hace 2m')
413
+ const aiHeader = el('div', 'flex-row gap-8 mb-4 flex-wrap')
414
+ aiHeader.append(el('div', 'ai-label', 'Análisis IA · HuggingFace Qwen3-8B'), aiBadge, aiMeta)
415
+
416
+ // AI text built with DOM nodes — no external string ever touches innerHTML
417
+ const aiText = el('div', 'ai-text')
418
+ aiText.textContent = sig.summary
419
+ if (sig.keyRisk) {
420
+ const strong = document.createElement('strong')
421
+ strong.textContent = 'Riesgo clave:'
422
+ aiText.append(' ', strong, ' ', sig.keyRisk)
423
+ }
424
+
425
+ const aiInner = el('div', 'flex-1')
426
+ aiInner.append(aiHeader, aiText)
427
+ const aiBox = el('div', 'ai-box')
428
+ aiBox.append(el('div', 'ai-icon', '◈'), aiInner)
429
+
430
+ // ── Simulator row ──
431
+ const simAmount = el('input', 'sim-input')
432
+ simAmount.id = 'sim-amount'
433
+ simAmount.type = 'number'
434
+ simAmount.value = '100'
435
+ simAmount.min = '1'
436
+ simAmount.placeholder = '€'
437
+
438
+ const simYes = el('button', 'sim-btn-yes', 'COMPRAR SÍ ↗')
439
+ simYes.id = 'sim-yes'
440
+ const simNo = el('button', 'sim-btn-no', 'COMPRAR NO')
441
+ simNo.id = 'sim-no'
442
+
443
+ const simRow = el('div', 'sim-row')
444
+ simRow.append(
445
+ el('span', 'sim-label', 'Simular posición →'),
446
+ simAmount,
447
+ simYes,
448
+ simNo,
449
+ el('span', 'sim-disclaimer', 'Simulado · sin trading real'),
450
+ )
451
+
452
+ container.replaceChildren(header, outcomesRow, aiBox, simRow)
453
 
454
  // Bind simulator buttons
455
+ simYes.addEventListener('click', () => simulator.openPosition(m.id, 'SÍ', simAmount.value))
456
+ simNo.addEventListener('click', () => simulator.openPosition(m.id, 'NO', simAmount.value))
457
 
458
+ // Render charts
459
  charts.renderDetailChart('detail-chart', m.yesPrice)
460
  charts.renderSparkline('spark-yes', m.yesPrice, 'yes')
461
  charts.renderSparkline('spark-no', m.noPrice, 'no')
 
475
  const empty = document.getElementById('positions-empty')
476
  if (!tbody) return
477
  if (state.positions.length === 0) {
478
+ tbody.replaceChildren()
479
  empty.classList.remove('hidden')
480
  return
481
  }
482
  empty.classList.add('hidden')
483
+ tbody.replaceChildren()
484
+
485
+ state.positions.forEach((p) => {
486
+ const m = state.markets.find((x) => x.id === p.marketId) || { question: p.marketId }
487
+ const pnlColor = p.pnl >= 0 ? 'td-green' : 'td-red'
488
+ const sign = p.pnl >= 0 ? '+' : ''
489
+
490
+ const tr = document.createElement('tr')
491
+
492
+ const tdQ = el('td')
493
+ tdQ.textContent = `${m.question.substring(0, 40)}${m.question.length > 40 ? '…' : ''}`
494
+
495
+ const tdOutcome = el('td', `td-mono ${p.outcome === 'SÍ' ? 'td-green' : 'td-red'}`)
496
+ tdOutcome.textContent = p.outcome
497
+
498
+ const tdAmt = el('td', 'td-mono')
499
+ tdAmt.textContent = `€${p.amountEur.toFixed(0)}`
500
+
501
+ const tdEntry = el('td', 'td-mono')
502
+ tdEntry.textContent = formatPrice(p.entryPrice)
503
+
504
+ const tdCurrent = el('td', 'td-mono')
505
+ tdCurrent.textContent = formatPrice(p.currentPrice)
506
+
507
+ const tdPnl = el('td', `td-mono ${pnlColor}`)
508
+ tdPnl.textContent = `${sign}€${p.pnl.toFixed(2)}`
509
+
510
+ const tdKelly = el('td', 'td-mono td-blue')
511
+ tdKelly.textContent = `${((p.kellyFraction || 0) * 100).toFixed(0)}%`
512
+
513
+ const tdDate = el('td', 'td-mono')
514
+ tdDate.textContent = formatDate(p.openedAt)
515
+
516
+ const btn = el('button', 'btn-ghost', 'Cerrar')
517
+ btn.addEventListener('click', () => closePositionById(p.id))
518
+ const tdBtn = el('td')
519
+ tdBtn.appendChild(btn)
520
+
521
+ tr.append(tdQ, tdOutcome, tdAmt, tdEntry, tdCurrent, tdPnl, tdKelly, tdDate, tdBtn)
522
+ tbody.appendChild(tr)
523
+ })
524
  }
525
 
526
+ async function closePositionById(id) {
527
  await simulator.closePosition(id)
528
  await loadPositions()
529
  renderPositions()
 
536
  const empty = document.getElementById('watchlist-empty')
537
  if (!tbody) return
538
  if (state.watchlist.length === 0) {
539
+ tbody.replaceChildren()
540
  empty.classList.remove('hidden')
541
  return
542
  }
543
  empty.classList.add('hidden')
544
+ tbody.replaceChildren()
545
+
546
+ state.watchlist.forEach((w) => {
547
+ const m = state.markets.find((x) => x.id === w.marketId) || { question: w.marketId, category: '-', yesPrice: 0, noPrice: 0, volumeEur: 0 }
548
+ const sig = state.signals.find((s) => s.marketId === w.marketId) || { signal: 'neutral' }
549
+
550
+ const tr = document.createElement('tr')
551
+
552
+ const tdQ = el('td')
553
+ tdQ.textContent = `${m.question.substring(0, 40)}${m.question.length > 40 ? '…' : ''}`
554
+
555
+ const tdCat = el('td')
556
+ tdCat.textContent = m.category || '-'
557
+
558
+ const tdYes = el('td', 'td-mono td-green')
559
+ tdYes.textContent = formatPrice(m.yesPrice)
560
+
561
+ const tdNo = el('td', 'td-mono td-red')
562
+ tdNo.textContent = formatPrice(m.noPrice)
563
+
564
+ const badge = el('span', `signal-badge ${getSignalBadgeClass(sig.signal)}`)
565
+ badge.textContent = getSignalLabel(sig.signal)
566
+ const tdSig = el('td')
567
+ tdSig.appendChild(badge)
568
+
569
+ const tdVol = el('td', 'td-mono')
570
+ tdVol.textContent = formatCurrency(m.volumeEur || 0)
571
+
572
+ const tdThreshold = el('td', 'td-mono')
573
+ tdThreshold.textContent = w.alertThreshold ? formatPrice(w.alertThreshold) : '-'
574
+
575
+ const btn = el('button', 'btn-ghost', 'Eliminar')
576
+ btn.addEventListener('click', () => removeFromWatchlistById(w.marketId))
577
+ const tdBtn = el('td')
578
+ tdBtn.appendChild(btn)
579
+
580
+ tr.append(tdQ, tdCat, tdYes, tdNo, tdSig, tdVol, tdThreshold, tdBtn)
581
+ tbody.appendChild(tr)
582
+ })
583
  }
584
 
585
+ async function removeFromWatchlistById(marketId) {
586
  try { await api.removeFromWatchlist(marketId) } catch (e) { console.warn(e) }
587
  state.watchlist = state.watchlist.filter((w) => w.marketId !== marketId)
588
  renderWatchlist()
 
594
  const empty = document.getElementById('alerts-empty')
595
  if (!tbody) return
596
  if (state.alerts.length === 0) {
597
+ tbody.replaceChildren()
598
  empty.classList.remove('hidden')
599
  return
600
  }
601
  empty.classList.add('hidden')
602
+ tbody.replaceChildren()
603
+
604
+ state.alerts.forEach((a) => {
605
+ const m = state.markets.find((x) => x.id === a.marketId) || { question: a.marketId }
606
+
607
+ const tr = document.createElement('tr')
608
+
609
+ const tdDate = el('td', 'td-mono')
610
+ tdDate.textContent = new Date(a.sentAt).toLocaleString('es-ES')
611
+
612
+ const tdQ = el('td')
613
+ tdQ.textContent = `${m.question.substring(0, 35)}${m.question.length > 35 ? '…' : ''}`
614
+
615
+ const typeBadge = el('span', 'signal-badge sig-neut')
616
+ typeBadge.textContent = a.type
617
+ const tdType = el('td')
618
+ tdType.appendChild(typeBadge)
619
+
620
+ const tdMsg = el('td')
621
+ tdMsg.textContent = a.message
622
+
623
+ tr.append(tdDate, tdQ, tdType, tdMsg)
624
+ tbody.appendChild(tr)
625
+ })
626
  }
627
 
628
  /* ─── Carga de datos ─── */
 
673
 
674
  /* ─── Inicialización ─── */
675
  export async function init() {
 
676
  document.getElementById('sidebar-toggle')?.addEventListener('click', toggleSidebar)
677
 
678
+ document.querySelectorAll('.nav-item').forEach((item) => {
679
+ item.addEventListener('click', () => switchView(item.dataset.view))
 
680
  })
681
 
682
+ document.querySelectorAll('.panel-header[data-panel]').forEach((item) => {
683
+ item.addEventListener('click', (e) => {
 
 
684
  if (e.target.closest('button, input, a')) return
685
+ togglePanel(item.dataset.panel)
686
  })
687
  })
688
 
 
689
  await loadMarkets()
690
  await loadSignals()
691
  await loadPositions()
692
  await loadWatchlist()
693
  await loadAlerts()
694
 
 
695
  map.init('map-container', state.markets, state.signals, selectMarket)
696
  simulator.init(state)
697
 
 
698
  state.activeMarketId = state.markets[0]?.id || null
699
  renderSignals()
700
  renderDetail()
701
  renderMiniPositions()
702
 
 
703
  const socket = io()
704
  socket.on('connect', () => console.log('Socket.io conectado'))
705
 
706
  socket.on('market_update', (data) => {
707
  const m = state.markets.find((x) => x.id === data.marketId)
708
  if (m) {
709
+ // Only copy known numeric fields — never merge the whole payload
710
+ if (typeof data.yesPrice === 'number') m.yesPrice = data.yesPrice
711
+ if (typeof data.noPrice === 'number') m.noPrice = data.noPrice
712
+ if (typeof data.volumeEur === 'number') m.volumeEur = data.volumeEur
713
+ if (typeof data.liquidityEur === 'number') m.liquidityEur = data.liquidityEur
714
  if (state.activeMarketId === data.marketId) renderDetail()
715
  renderSignals()
716
  map.updateBubble(data.marketId, data.yesPrice)
 
718
  })
719
 
720
  socket.on('ai_signal', (data) => {
721
+ if (!data?.marketId || typeof data.signal !== 'string') return
722
  const idx = state.signals.findIndex((s) => s.marketId === data.marketId)
723
  if (idx >= 0) state.signals[idx] = data
724
  else state.signals.push(data)
 
727
  })
728
 
729
  socket.on('price_alert', (data) => {
730
+ if (!data?.marketId || !data.type) return
731
  state.alerts.unshift(data)
732
  if (state.view === 'alerts') renderAlerts()
733
  })
734
 
 
735
  setInterval(() => {
736
+ const statMarkets = document.getElementById('stat-markets')
737
+ if (statMarkets) {
738
+ const n = parseInt(statMarkets.textContent.replace(/\./g, '')) + Math.floor(Math.random() * 3)
739
+ statMarkets.textContent = n.toLocaleString('es-ES')
740
  }
741
+ const statSignals = document.getElementById('stat-signals')
742
+ if (statSignals && Math.random() > 0.7) {
743
+ statSignals.textContent = parseInt(statSignals.textContent) + 1
 
744
  }
745
  }, 3000)
746
  }
frontend/src/map.js CHANGED
@@ -92,27 +92,41 @@ export function init(containerId, markets, signals, onSelect) {
92
  fillOpacity: 0.8,
93
  }).addTo(mapInstance)
94
 
 
 
 
 
 
95
  const label = L.marker(coords, {
96
  icon: L.divIcon({
97
  className: 'map-label',
98
- html: `<span class="map-label-text">${m.countryCode || 'GL'}</span>`,
99
  iconSize: [40, 14],
100
  iconAnchor: [20, -radius - 4],
101
  }),
102
  interactive: false,
103
  }).addTo(mapInstance)
104
 
105
- const popupContent = `
106
- <div class="map-popup">
107
- <div class="map-popup-cat">${m.category || 'General'} · ${m.countryCode || 'GL'}</div>
108
- <div class="map-popup-q">${m.question}</div>
109
- <div class="map-popup-prices">
110
- <span class="text-green">SÍ ${Math.round((m.yesPrice || 0) * 100)}¢</span>
111
- <span class="text-red">NO ${Math.round((m.noPrice || 0) * 100)}¢</span>
112
- </div>
113
- </div>
114
- `
115
- circle.bindPopup(popupContent, { closeButton: false, offset: [0, -4] })
 
 
 
 
 
 
 
 
 
116
 
117
  circle.on('click', () => {
118
  onSelect(m.id)
 
92
  fillOpacity: 0.8,
93
  }).addTo(mapInstance)
94
 
95
+ // Build label text safely via textContent before getting outerHTML
96
+ const labelSpan = document.createElement('span')
97
+ labelSpan.className = 'map-label-text'
98
+ labelSpan.textContent = m.countryCode || 'GL'
99
+
100
  const label = L.marker(coords, {
101
  icon: L.divIcon({
102
  className: 'map-label',
103
+ html: labelSpan.outerHTML,
104
  iconSize: [40, 14],
105
  iconAnchor: [20, -radius - 4],
106
  }),
107
  interactive: false,
108
  }).addTo(mapInstance)
109
 
110
+ // Build popup DOM — textContent prevents any HTML injection from market data
111
+ const popup = document.createElement('div')
112
+ popup.className = 'map-popup'
113
+ const popupCat = document.createElement('div')
114
+ popupCat.className = 'map-popup-cat'
115
+ popupCat.textContent = `${m.category || 'General'} · ${m.countryCode || 'GL'}`
116
+ const popupQ = document.createElement('div')
117
+ popupQ.className = 'map-popup-q'
118
+ popupQ.textContent = m.question
119
+ const popupPrices = document.createElement('div')
120
+ popupPrices.className = 'map-popup-prices'
121
+ const yesSpan = document.createElement('span')
122
+ yesSpan.className = 'text-green'
123
+ yesSpan.textContent = `SÍ ${Math.round((m.yesPrice || 0) * 100)}¢`
124
+ const noSpan = document.createElement('span')
125
+ noSpan.className = 'text-red'
126
+ noSpan.textContent = `NO ${Math.round((m.noPrice || 0) * 100)}¢`
127
+ popupPrices.append(yesSpan, noSpan)
128
+ popup.append(popupCat, popupQ, popupPrices)
129
+ circle.bindPopup(popup, { closeButton: false, offset: [0, -4] })
130
 
131
  circle.on('click', () => {
132
  onSelect(m.id)