ghassan 73527f3781 Add sports-match type, device tracking, profile visits, and share refactor
- New SportsMatch model/controller and sports UI components/modal
- Move share-modal to a reusable x-share-modal/x-share-button component
- Add VideoSharedWithUser notification and share-to-members flow
- Device/user-agent tracking on views, downloads, share accesses
- ProfileVisit model + migration; subscription source tracking
- Email thumbnail support; remove stale TODO files
2026-05-29 01:50:28 +03:00

1323 lines
89 KiB
PHP
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

{{--
Video Insights component.
Expects: $video (Video model)
Renders the Insights tab panel + modal + all JS only for the video owner.
--}}
@php
$isVideoOwner = Auth::check() && Auth::id() === $video->user_id;
@endphp
@if($isVideoOwner)
<style>
/* ── Stat cards ──────────────────────────────────────── */
.ins-grid { display:grid; grid-template-columns:repeat(7,minmax(0,1fr)); gap:8px; margin-bottom:18px; }
@media(max-width:1100px){ .ins-grid { grid-template-columns:repeat(4,minmax(0,1fr)); } }
@media(max-width:760px) { .ins-grid { grid-template-columns:repeat(3,minmax(0,1fr)); } }
@media(max-width:480px) { .ins-grid { grid-template-columns:repeat(2,minmax(0,1fr)); } }
.ins-card { background:var(--bg-dark); border:1px solid var(--border-color); border-radius:10px; padding:14px 14px 12px; display:flex; flex-direction:column; gap:4px; cursor:pointer; transition:border-color .15s, transform .12s, box-shadow .15s; user-select:none; }
.ins-card:hover { border-color:rgba(239,68,68,.5); transform:translateY(-2px); box-shadow:0 4px 16px rgba(0,0,0,.3); }
.ins-card:active { transform:translateY(0); }
.ins-card-icon { font-size:18px; margin-bottom:2px; }
.ins-card-val { font-size:26px; font-weight:900; line-height:1; color:var(--text-primary); }
.ins-card-label { font-size:11px; font-weight:600; text-transform:uppercase; letter-spacing:.05em; color:var(--text-secondary); }
.ins-card-sub { font-size:12px; margin-top:3px; font-weight:600; }
.ins-card-sub.up { color:#4ade80; } .ins-card-sub.down { color:#f87171; } .ins-card-sub.neu { color:var(--text-secondary); }
/* ── Section titles / charts ────────────────────────── */
.ins-section-title { font-size:12px; font-weight:700; text-transform:uppercase; letter-spacing:.06em; color:var(--text-secondary); margin-bottom:12px; display:flex; align-items:center; gap:6px; }
.ins-chart { display:flex; align-items:flex-end; gap:4px; height:80px; margin-bottom:6px; }
.ins-bar-col { flex:1; display:flex; flex-direction:column; align-items:center; gap:4px; min-width:0; }
.ins-bar { width:100%; border-radius:4px 4px 0 0; background:#ef444466; transition:height .6s cubic-bezier(.22,.61,.36,1), background .15s; min-height:2px; }
.ins-bar.clickable { cursor:pointer; }
.ins-bar.clickable:hover { background:#ef4444cc !important; }
.ins-bar-today { background:#ef4444 !important; }
/* Segmented bar: three stacked colour blocks (male / female / other-or-guest) */
.ins-bar-seg { width:100%; display:flex; flex-direction:column; justify-content:flex-end; border-radius:4px 4px 0 0; overflow:hidden; transition:filter .15s, transform .12s; }
.ins-bar-seg.clickable { cursor:pointer; }
.ins-bar-seg.clickable:hover { filter:brightness(1.18); }
.ins-bar-seg .seg { width:100%; transition:height .6s cubic-bezier(.22,.61,.36,1); }
.ins-bar-seg .seg-female { background:#ef4444; } /* female — brand red */
.ins-bar-seg .seg-male { background:#f59e0b; } /* male — warm amber */
.ins-bar-seg .seg-other { background:rgba(255,255,255,.18); } /* other / guest — neutral */
.ins-bar-seg.today { box-shadow:0 0 0 1px rgba(255,255,255,.55) inset; }
.ins-bar-legend { display:flex; gap:14px; flex-wrap:wrap; margin-top:8px; font-size:11px; color:var(--text-secondary); }
.ins-bar-legend span { display:inline-flex; align-items:center; gap:5px; }
.ins-bar-legend i { width:10px; height:10px; border-radius:2px; display:inline-block; }
.ins-bar-label { font-size:9px; color:var(--text-secondary); text-align:center; overflow:hidden; white-space:nowrap; }
.ins-peak-badge { display:inline-flex; align-items:center; gap:6px; background:rgba(239,68,68,.12); border:1px solid rgba(239,68,68,.25); border-radius:20px; padding:4px 12px; font-size:12px; font-weight:600; color:#fca5a5; }
.ins-body { display:grid; grid-template-columns:1fr 1fr; gap:20px; }
@media(max-width:600px){ .ins-body { grid-template-columns:1fr; } }
/* ── Country rows ───────────────────────────────────── */
.ins-country-row { display:flex; align-items:center; gap:10px; padding:7px 0; border-bottom:1px solid rgba(255,255,255,.04); cursor:pointer; border-radius:6px; transition:background .12s; }
.ins-country-row:last-child { border-bottom:none; }
.ins-country-row:hover { background:rgba(255,255,255,.04); padding-left:6px; }
.ins-country-flag { font-size:20px; flex-shrink:0; width:28px; text-align:center; line-height:1; }
.ins-country-name { flex:1; font-size:13px; font-weight:500; color:var(--text-primary); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
.ins-country-bar-wrap { width:100px; height:5px; background:rgba(255,255,255,.07); border-radius:4px; overflow:hidden; flex-shrink:0; }
.ins-country-bar { height:100%; background:#ef4444; border-radius:4px; }
.ins-country-pct { font-size:12px; font-weight:700; color:var(--text-secondary); min-width:34px; text-align:right; }
.ins-country-cnt { font-size:11px; color:var(--text-secondary); min-width:40px; text-align:right; }
/* ── Download rows ───────────────────────────────────── */
.ins-dl-header { display:flex; align-items:center; justify-content:space-between; margin-bottom:12px; flex-wrap:wrap; gap:8px; }
.ins-dl-type-pills { display:flex; gap:6px; }
.ins-dl-pill { display:inline-flex; align-items:center; gap:5px; border-radius:20px; padding:3px 11px; font-size:12px; font-weight:600; }
.ins-dl-pill.video { border:1px solid rgba(239,68,68,.3); color:#fca5a5; background:rgba(239,68,68,.08); }
.ins-dl-pill.mp3 { border:1px solid rgba(96,165,250,.3); color:#93c5fd; background:rgba(96,165,250,.08); }
.ins-dl-pill.guest { border:1px solid rgba(255,255,255,.12); color:var(--text-secondary); background:rgba(255,255,255,.05); }
.ins-dl-user-row { display:flex; align-items:center; gap:10px; padding:8px 6px; border-bottom:1px solid rgba(255,255,255,.04); cursor:pointer; border-radius:8px; transition:background .12s; }
.ins-dl-user-row:last-child { border-bottom:none; }
.ins-dl-user-row:hover { background:rgba(255,255,255,.05); }
.ins-dl-avatar { width:34px; height:34px; border-radius:50%; object-fit:cover; flex-shrink:0; background:rgba(255,255,255,.08); }
.ins-dl-user-name { flex:1; font-size:13px; font-weight:600; color:var(--text-primary); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.ins-dl-user-meta { font-size:11px; color:var(--text-secondary); margin-top:1px; }
.ins-dl-count-badge { flex-shrink:0; background:rgba(239,68,68,.12); border:1px solid rgba(239,68,68,.25); color:#fca5a5; border-radius:20px; font-size:12px; font-weight:700; padding:2px 10px; }
.ins-recent-row { display:flex; align-items:center; gap:10px; padding:7px 6px; border-bottom:1px solid rgba(255,255,255,.04); font-size:12px; border-radius:6px; transition:background .12s; }
.ins-recent-row.clickable { cursor:pointer; }
.ins-recent-row.clickable:hover { background:rgba(255,255,255,.05); }
.ins-recent-row:last-child { border-bottom:none; }
.ins-recent-count { flex-shrink:0; background:rgba(239,68,68,.12); border:1px solid rgba(239,68,68,.25); color:#fca5a5; border-radius:20px; font-size:11px; font-weight:700; padding:2px 8px; }
.ins-recent-scroll { max-height:430px; overflow-y:auto; padding-right:4px; }
.ins-recent-scroll::-webkit-scrollbar { width:6px; }
.ins-recent-scroll::-webkit-scrollbar-thumb { background:rgba(255,255,255,.12); border-radius:3px; }
.ins-recent-scroll::-webkit-scrollbar-thumb:hover { background:rgba(255,255,255,.2); }
/* UA bucket rows inside the viewer-detail modal */
.ins-ua-row { display:flex; align-items:center; gap:10px; padding:7px 0; border-bottom:1px solid rgba(255,255,255,.04); font-size:13px; }
.ins-ua-row:last-child { border-bottom:none; }
.ins-ua-icon { font-size:16px; flex-shrink:0; width:22px; text-align:center; color:var(--text-secondary); }
.ins-ua-label { flex:1; color:var(--text-primary); font-weight:500; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.ins-ua-bar-wrap { width:80px; height:5px; background:rgba(255,255,255,.07); border-radius:4px; overflow:hidden; flex-shrink:0; }
.ins-ua-bar { height:100%; background:#ef4444; border-radius:4px; }
.ins-ua-cnt { font-size:11px; color:var(--text-secondary); min-width:30px; text-align:right; }
.ins-recent-avatar { width:28px; height:28px; border-radius:50%; object-fit:cover; flex-shrink:0; background:rgba(255,255,255,.08); display:flex; align-items:center; justify-content:center; font-size:14px; }
.ins-recent-name { flex:1; font-weight:600; color:var(--text-primary); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.ins-recent-type { font-size:10px; font-weight:700; text-transform:uppercase; letter-spacing:.05em; padding:2px 7px; border-radius:6px; flex-shrink:0; }
.ins-recent-type.video { background:rgba(239,68,68,.12); color:#fca5a5; }
.ins-recent-type.mp3 { background:rgba(96,165,250,.1); color:#93c5fd; }
.ins-recent-time { color:var(--text-secondary); flex-shrink:0; min-width:70px; text-align:right; }
.ins-recent-flag { font-size:16px; flex-shrink:0; }
/* ── Demographics (gender / age) ─────────────────────── */
.ins-demo-row { display:flex; align-items:center; gap:8px; padding:5px 0; }
.ins-demo-sym { font-size:16px; flex-shrink:0; width:18px; text-align:center; }
.ins-demo-label { font-size:12px; font-weight:600; color:var(--text-primary); min-width:52px; }
.ins-age-label { min-width:68px; }
.ins-demo-bar-wrap { flex:1; height:6px; background:rgba(255,255,255,.07); border-radius:4px; overflow:hidden; }
.ins-demo-bar { height:100%; border-radius:4px; transition:width .6s cubic-bezier(.22,.61,.36,1); }
.ins-demo-pct { font-size:12px; font-weight:700; color:var(--text-secondary); min-width:34px; text-align:right; }
.ins-demo-cnt { font-size:11px; color:var(--text-secondary); min-width:30px; text-align:right; }
/* ── Two-column grid (collapses to 1 col on mobile) ─── */
.ins-two-col { display:grid; grid-template-columns:1fr 1fr; gap:20px; }
@media(max-width:600px){ .ins-two-col { grid-template-columns:1fr; } }
/* ── Who Liked rows ──────────────────────────────────── */
.ins-liker-row { display:flex; align-items:center; gap:10px; padding:7px 6px; border-bottom:1px solid rgba(255,255,255,.04); cursor:pointer; border-radius:8px; transition:background .12s; }
.ins-liker-row:last-child { border-bottom:none; }
.ins-liker-row:hover { background:rgba(255,255,255,.05); }
.ins-liker-avatar { width:34px; height:34px; border-radius:50%; object-fit:cover; flex-shrink:0; background:rgba(255,255,255,.08); }
.ins-liker-name { flex:1; font-size:13px; font-weight:600; color:var(--text-primary); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.ins-liker-time { font-size:11px; color:var(--text-secondary); flex-shrink:0; }
/* ── Skeleton ─────────────────────────────────────────── */
.ins-skeleton { display:flex; flex-direction:column; gap:12px; padding:4px 0; }
.ins-skel-row { height:14px; border-radius:6px; background:rgba(255,255,255,.06); animation:skelPulse 1.4s ease-in-out infinite; }
@keyframes skelPulse { 0%,100%{opacity:.5} 50%{opacity:1} }
/* ── Insight modal ───────────────────────────────────── */
.ins-modal-backdrop { position:fixed; inset:0; background:rgba(0,0,0,.72); z-index:9200; display:flex; align-items:center; justify-content:center; padding:16px; animation:insBackIn .15s ease; }
@keyframes insBackIn { from{opacity:0} to{opacity:1} }
.ins-modal { background:var(--bg-secondary); border:1px solid var(--border-color); border-radius:18px; width:100%; max-width:500px; max-height:84vh; display:flex; flex-direction:column; overflow:hidden; animation:insModalIn .22s cubic-bezier(.34,1.4,.64,1); }
@keyframes insModalIn { from{opacity:0;transform:scale(.88) translateY(20px)} to{opacity:1;transform:none} }
.ins-modal-head { display:flex; align-items:center; gap:12px; padding:16px 18px 13px; border-bottom:1px solid var(--border-color); flex-shrink:0; }
.ins-modal-icon { font-size:26px; line-height:1; }
.ins-modal-titles { flex:1; min-width:0; }
.ins-modal-title { font-size:15px; font-weight:700; color:var(--text-primary); }
.ins-modal-subtitle { font-size:12px; color:var(--text-secondary); margin-top:1px; }
.ins-modal-close { background:rgba(255,255,255,.06); border:none; color:var(--text-secondary); border-radius:50%; width:30px; height:30px; cursor:pointer; display:flex; align-items:center; justify-content:center; font-size:16px; transition:background .15s,color .15s; flex-shrink:0; }
.ins-modal-close:hover { background:rgba(239,68,68,.2); color:#fca5a5; }
.ins-modal-body { padding:18px 18px 22px; overflow-y:auto; flex:1; }
/* Modal content pieces */
.ins-modal-hero { text-align:center; padding:4px 0 20px; }
.ins-modal-hero-num { font-size:52px; font-weight:900; line-height:1; color:var(--text-primary); }
.ins-modal-hero-flag { font-size:56px; line-height:1; margin-bottom:4px; }
.ins-modal-hero-label { font-size:13px; color:var(--text-secondary); margin-top:6px; }
.ins-modal-stat { display:flex; justify-content:space-between; align-items:center; padding:9px 0; border-bottom:1px solid rgba(255,255,255,.05); font-size:13px; }
.ins-modal-stat:last-child { border-bottom:none; }
.ins-modal-stat-lbl { color:var(--text-secondary); font-weight:500; display:flex; align-items:center; gap:7px; }
.ins-modal-stat-val { font-weight:700; color:var(--text-primary); }
.ins-modal-section { font-size:11px; font-weight:700; text-transform:uppercase; letter-spacing:.06em; color:var(--text-secondary); margin:18px 0 10px; display:flex; align-items:center; gap:6px; }
.ins-modal-section:first-child { margin-top:0; }
/* Hourly chart (24 bars) */
.ins-hourly-chart { display:flex; align-items:flex-end; gap:2px; height:60px; margin-bottom:5px; }
.ins-hourly-col { flex:1; display:flex; flex-direction:column; align-items:center; gap:2px; min-width:0; }
.ins-hourly-bar { width:100%; border-radius:3px 3px 0 0; background:#ef444455; min-height:2px; transition:height .5s cubic-bezier(.22,.61,.36,1); }
.ins-hourly-bar.peak { background:#ef4444; }
.ins-hourly-lbl { font-size:7px; color:var(--text-secondary); text-align:center; overflow:hidden; white-space:nowrap; }
/* Person list */
.ins-person-row { display:flex; align-items:center; gap:10px; padding:9px 0; border-bottom:1px solid rgba(255,255,255,.04); cursor:pointer; border-radius:6px; transition:background .12s; }
.ins-person-row:last-child { border-bottom:none; }
.ins-person-row:hover { background:rgba(255,255,255,.04); padding-left:6px; }
.ins-person-avatar { width:36px; height:36px; border-radius:50%; object-fit:cover; flex-shrink:0; background:rgba(255,255,255,.08); }
.ins-person-name { flex:1; font-size:13px; font-weight:600; color:var(--text-primary); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.ins-person-meta { font-size:11px; color:var(--text-secondary); margin-top:1px; }
.ins-person-badge { flex-shrink:0; background:rgba(239,68,68,.1); border:1px solid rgba(239,68,68,.25); color:#fca5a5; border-radius:20px; font-size:11px; font-weight:700; padding:2px 9px; }
.ins-guest-row { display:flex; align-items:center; gap:10px; padding:8px 0; border-bottom:1px solid rgba(255,255,255,.04); font-size:12px; }
.ins-guest-row:last-child { border-bottom:none; }
.ins-guest-flag-big { font-size:22px; flex-shrink:0; }
/* Download history entries */
.ins-dl-hist-row { display:flex; align-items:center; gap:10px; padding:9px 12px; border-radius:8px; background:rgba(255,255,255,.03); margin-bottom:6px; font-size:12px; }
.ins-dl-hist-num { font-size:11px; font-weight:700; color:var(--text-secondary); min-width:18px; text-align:center; }
.ins-dl-hist-type { font-size:10px; font-weight:700; text-transform:uppercase; padding:2px 8px; border-radius:6px; flex-shrink:0; }
.ins-dl-hist-type.video { background:rgba(239,68,68,.12); color:#fca5a5; }
.ins-dl-hist-type.mp3 { background:rgba(96,165,250,.1); color:#93c5fd; }
.ins-dl-hist-time { flex:1; color:var(--text-secondary); }
.ins-dl-hist-flag { font-size:15px; flex-shrink:0; }
/* Loading state inside modal */
.ins-modal-loading { display:flex; flex-direction:column; align-items:center; justify-content:center; padding:40px 0; gap:10px; color:var(--text-secondary); font-size:13px; }
.ins-modal-spin { width:28px; height:28px; border:3px solid rgba(255,255,255,.1); border-top-color:#ef4444; border-radius:50%; animation:spin .7s linear infinite; }
@keyframes spin { to{transform:rotate(360deg)} }
</style>
{{-- Panel: placed inside .vdb-wrap right after the About panel --}}
<div class="vdb-panel" id="vdb-insights"
data-insights-base="{{ url('/videos/' . $video->getRouteKey() . '/insights') }}">
<div id="insightsContent">
<div class="ins-skeleton">
<div class="ins-skel-row" style="width:60%;height:18px;"></div>
<div class="ins-skel-row" style="width:100%;height:80px;"></div>
<div class="ins-skel-row" style="width:80%;"></div>
<div class="ins-skel-row" style="width:90%;"></div>
<div class="ins-skel-row" style="width:70%;"></div>
</div>
</div>
</div>
{{-- Modal (position:fixed, nesting doesn't matter) --}}
<div id="insModalBackdrop" class="ins-modal-backdrop" style="display:none;" onclick="if(event.target===this)closeInsModal()">
<div class="ins-modal" onclick="event.stopPropagation()">
<div class="ins-modal-head">
<span class="ins-modal-icon" id="insModalIcon"></span>
<div class="ins-modal-titles">
<div class="ins-modal-title" id="insModalTitle"></div>
<div class="ins-modal-subtitle" id="insModalSubtitle"></div>
</div>
<button class="ins-modal-close" onclick="closeInsModal()"><i class="bi bi-x-lg"></i></button>
</div>
<div class="ins-modal-body" id="insModalBody"></div>
</div>
</div>
<script>
window._insLoaded = false;
window._insLoadedUrl = null;
window._insData = null;
// ── Helpers ────────────────────────────────────────────
const _cflag = code => `<span class="fi fi-${(!code||code.length!==2)?'xx':code.toLowerCase()}" style="width:20px;height:15px;border-radius:2px;display:inline-block;flex-shrink:0;vertical-align:middle;"></span>`;
// Compact device/browser line for row meta (e.g. "📱 iPhone · Chrome 124")
function _uaInline(o) {
const dev = o && o.device ? o.device : '';
const br = o && o.browser ? o.browser : '';
if (!dev && !br) return '<span style="opacity:.6;">Device unknown</span>';
let icon = '<i class="bi bi-display"></i>';
if (/iPhone|Android phone|Mobile/i.test(dev)) icon = '<i class="bi bi-phone"></i>';
else if (/iPad|Tablet/i.test(dev)) icon = '<i class="bi bi-tablet"></i>';
const parts = [dev, br].filter(Boolean).join(' · ');
return `${icon} ${parts}`;
}
const _fmt = n => { n=n??0; if(n>=1e6) return (n/1e6).toFixed(1).replace(/\.0$/,'')+'M'; if(n>=1e3) return (n/1e3).toFixed(1).replace(/\.0$/,'')+'K'; return String(n); };
const _fmtH = h => (h===null||h===undefined) ? '' : (h%12||12)+':00 '+(h>=12?'PM':'AM');
const _ago = s => { const d=Math.floor((Date.now()-new Date(s))/1000); if(d<60) return d+'s ago'; if(d<3600) return Math.floor(d/60)+'m ago'; if(d<86400) return Math.floor(d/3600)+'h ago'; return Math.floor(d/86400)+'d ago'; };
const _fmtDt = s => new Date(s).toLocaleDateString('en-US',{month:'short',day:'numeric',hour:'numeric',minute:'2-digit'});
// ── Modal helpers ──────────────────────────────────────
function _openModal(icon, title, subtitle, html) {
document.getElementById('insModalIcon').innerHTML = icon;
document.getElementById('insModalTitle').textContent = title;
document.getElementById('insModalSubtitle').textContent = subtitle || '';
document.getElementById('insModalBody').innerHTML = html;
document.getElementById('insModalBackdrop').style.display = 'flex';
document.body.style.overflow = 'hidden';
}
function _modalLoading(icon, title, subtitle) {
_openModal(icon, title, subtitle,
`<div class="ins-modal-loading"><div class="ins-modal-spin"></div>Loading details…</div>`);
}
function closeInsModal() {
document.getElementById('insModalBackdrop').style.display = 'none';
document.body.style.overflow = '';
}
document.addEventListener('keydown', e => { if(e.key==='Escape') closeInsModal(); });
// ── Person list HTML (shared) ──────────────────────────
function _personListHtml(users, emptyMsg) {
if (!users || !users.length) return `<p style="font-size:13px;color:var(--text-secondary);margin:0;">${emptyMsg}</p>`;
return users.map((u,i) => `
<div class="ins-person-row" onclick="window.location.href='/channel/${u.channel||u.id}'" title="View ${u.name}'s profile">
<div style="font-size:11px;font-weight:700;color:var(--text-secondary);min-width:18px;text-align:center;">${i+1}</div>
<img src="${u.avatar}" alt="${u.name}" class="ins-person-avatar">
<div style="flex:1;min-width:0;">
<div class="ins-person-name">${u.name}</div>
<div class="ins-person-meta">Last seen ${_ago(u.last_at)}</div>
</div>
<div class="ins-person-badge">👁 ${u.count}×</div>
</div>`).join('');
}
// ── Segmented (stacked) daily bars: male / female / other-or-guest ─────
function _segmentedBars(daily, maxHeightPx, opts) {
opts = opts || {};
const max = Math.max(...daily.map(d => d.count), 1);
const todayLbl = new Date().toLocaleDateString('en-US', { month:'short', day:'numeric' });
return daily.map(day => {
const totalH = Math.max(Math.round((day.count / max) * maxHeightPx), day.count > 0 ? 2 : 0);
const male = day.male || 0;
const female = day.female || 0;
const other = day.other || 0;
// Each segment's pixel share rounds against the total so they always sum exactly to totalH
const t = day.count || 1;
const mH = totalH > 0 ? Math.round(totalH * (male / t)) : 0;
const fH = totalH > 0 ? Math.round(totalH * (female / t)) : 0;
const oH = Math.max(0, totalH - mH - fH);
const isToday = day.label === todayLbl;
const tip = `${day.label}: ${day.count} views\n${female} · ♂ ${male} · other/guest ${other}`;
const clickAttr = opts.onClick
? `onclick="${opts.onClick.replace('__DATE__', day.date).replace('__LABEL__', day.label).replace('__COUNT__', day.count)}"`
: '';
return `<div class="ins-bar-col">
<div class="ins-bar-seg${opts.onClick?' clickable':''}${isToday?' today':''}"
style="height:${totalH || 2}px;" title="${tip}" ${clickAttr}>
<div class="seg seg-female" style="height:${fH}px;"></div>
<div class="seg seg-male" style="height:${mH}px;"></div>
<div class="seg seg-other" style="height:${oH}px;"></div>
</div>
<div class="ins-bar-label">${day.short}</div>
</div>`;
}).join('');
}
const _segLegendHtml = `<div class="ins-bar-legend">
<span><i style="background:#ef4444;"></i> Female</span>
<span><i style="background:#f59e0b;"></i> Male</span>
<span><i style="background:rgba(255,255,255,.18);"></i> Other / Guest</span>
</div>`;
// ── Mini bar chart HTML (shared) ───────────────────────
function _miniBarChart(daily) {
const max = Math.max(...daily.map(d=>d.count), 1);
const today = new Date().toLocaleDateString('en-US',{month:'short',day:'numeric'});
return daily.map(d => {
const h = Math.round((d.count/max)*56);
const isT = d.label === today;
return `<div class="ins-bar-col">
<div class="ins-bar${isT?' ins-bar-today':''}" style="height:${Math.max(h,2)}px;" title="${d.label}: ${d.count}"></div>
<div class="ins-bar-label">${d.short}</div>
</div>`;
}).join('');
}
// ── Hourly chart HTML ──────────────────────────────────
function _hourlyChart(hourly) {
const max = Math.max(...hourly.map(h=>h.count), 1);
const peak = hourly.reduce((a,b)=>b.count>a.count?b:a, hourly[0]);
const bars = hourly.map(h => {
const ht = Math.round((h.count/max)*54);
const isPk = h.hour === peak.hour && peak.count > 0;
return `<div class="ins-hourly-col">
<div class="ins-hourly-bar${isPk?' peak':''}" style="height:${Math.max(ht,2)}px;" title="${h.label}: ${h.count}"></div>
<div class="ins-hourly-lbl">${h.hour%6===0?h.label:''}</div>
</div>`;
}).join('');
const peakH = peak.count > 0
? `<div style="margin-top:8px;"><span class="ins-peak-badge"><i class="bi bi-clock-fill"></i> Peak ${_fmtH(peak.hour)} · ${peak.count} views</span></div>`
: '';
return `<div class="ins-hourly-chart">${bars}</div>${peakH}`;
}
// ══════════════════════════════════════════════════════
// LOAD MAIN INSIGHTS
// ══════════════════════════════════════════════════════
function loadInsights() {
const panel = document.getElementById('vdb-insights');
const baseUrl = panel && panel.dataset.insightsBase;
if (!baseUrl) return;
// Reset to skeleton so stale data from the previous video never shows
document.getElementById('insightsContent').innerHTML =
`<div class="ins-skeleton">
<div class="ins-skel-row" style="width:60%;height:18px;"></div>
<div class="ins-skel-row" style="width:100%;height:80px;"></div>
<div class="ins-skel-row" style="width:80%;"></div>
<div class="ins-skel-row" style="width:90%;"></div>
<div class="ins-skel-row" style="width:70%;"></div>
</div>`;
fetch(baseUrl, { headers:{'Accept':'application/json','X-Requested-With':'XMLHttpRequest'} })
.then(r=>{ if(!r.ok) throw r; return r.json(); })
.then(d => { window._insLoaded=true; window._insLoadedUrl=baseUrl; window._insData=d; renderInsights(d); })
.catch(() => {
document.getElementById('insightsContent').innerHTML =
'<p style="color:var(--text-secondary);font-size:13px;padding:8px 0;">Could not load insights. Try again later.</p>';
});
}
function renderInsights(d) {
const weekBadge = d.week_change>0
? `<span class="ins-card-sub up"> ${d.week_change}% vs last week</span>`
: d.week_change<0
? `<span class="ins-card-sub down"> ${Math.abs(d.week_change)}% vs last week</span>`
: `<span class="ins-card-sub neu"> same as last week</span>`;
const dlSub = (d.dl_video||d.dl_mp3)
? `<span class="ins-card-sub neu">🎬 ${_fmt(d.dl_video)} · 🎵 ${_fmt(d.dl_mp3)}</span>`
: `<span class="ins-card-sub neu">no downloads yet</span>`;
// ── Stat cards (open top-level modal on click) ──────
const cards = `
<p style="font-size:11px;color:var(--text-secondary);margin:0 0 8px;display:flex;align-items:center;gap:5px;">
<i class="bi bi-cursor-fill"></i> Tap any card, chart bar, or country for a detailed breakdown
</p>
<div class="ins-grid">
<div class="ins-card" onclick="openInsModal_views()" title="Total views + skip rate">
<div class="ins-card-icon">👁️</div><div class="ins-card-val">${_fmt(d.total_views)}</div>
<div class="ins-card-label">Views</div>
<span class="ins-card-sub neu">${d.skip_rate||0}% skipped · ${_fmt(d.views_today)} today</span>
</div>
<div class="ins-card" onclick="openInsModal_reach()" title="Distinct people who saw this video">
<div class="ins-card-icon">📡</div><div class="ins-card-val">${_fmt(d.accounts_reached||0)}</div>
<div class="ins-card-label">Reach</div>
<span class="ins-card-sub neu">${_fmt(d.reached_users||0)} signed-in · ${_fmt(d.reached_guests||0)} guests</span>
</div>
<div class="ins-card" onclick="openInsModal_likes()" title="People who liked this video">
<div class="ins-card-icon">❤️</div><div class="ins-card-val">${_fmt(d.likes)}</div>
<div class="ins-card-label">Likes</div>
<span class="ins-card-sub neu">${d.likes===0?'no likes yet':_fmt(d.likes)+' '+(d.likes===1?'person':'people')}</span>
</div>
<div class="ins-card" onclick="openInsModal_saves()" title="Viewers who added this video to a playlist">
<div class="ins-card-icon">🔖</div><div class="ins-card-val">${_fmt(d.save_count||0)}</div>
<div class="ins-card-label">Saves</div>
<span class="ins-card-sub neu">${d.save_rate||0}% of viewers</span>
</div>
<div class="ins-card" onclick="openInsModal_comments()" title="Comments and replies on this video">
<div class="ins-card-icon">💬</div><div class="ins-card-val">${_fmt(d.comments_count||0)}</div>
<div class="ins-card-label">Comments</div>
<span class="ins-card-sub neu">${d.comments_count===1?'1 comment':_fmt(d.comments_count||0)+' total'}</span>
</div>
<div class="ins-card" onclick="openInsModal_downloads()">
<div class="ins-card-icon">⬇️</div><div class="ins-card-val">${_fmt(d.downloads)}</div>
<div class="ins-card-label">Downloads</div>${dlSub}
</div>
<div class="ins-card" onclick="openInsModal_conversions()" title="Downstream actions this video drove">
<div class="ins-card-icon">🚀</div><div class="ins-card-val">${_fmt((d.shares||0)+(d.profile_visits||0)+(d.new_subscribers||0))}</div>
<div class="ins-card-label">Conversions</div>
<span class="ins-card-sub neu">🔗 ${_fmt(d.shares||0)} · 🚪 ${_fmt(d.profile_visits||0)} · ${_fmt(d.new_subscribers||0)}</span>
</div>
</div>`;
// ── Bar chart (segmented + each bar clickable) ──────
const bars = _segmentedBars(d.daily, 72, {
onClick: `openDayDetail('__DATE__','__LABEL__',__COUNT__)`
});
const peakInline = d.peak_hour!==null
? ` <span style="font-size:10px;opacity:.7;font-weight:600;text-transform:none;color:#fca5a5;"><i class="bi bi-clock-fill"></i> Peak ${_fmtH(d.peak_hour)}</span>` : '';
// Only render the trend chart when there is at least one view to plot
const chartSection = (d.total_views > 0) ? `
<div>
<div class="ins-section-title"><i class="bi bi-graph-up"></i> Views last 14 days <span style="font-size:10px;opacity:.5;font-weight:400;text-transform:none;">(tap bar for day detail)</span>${peakInline}</div>
<div class="ins-chart">${bars}</div>
${_segLegendHtml}
<div style="font-size:11px;color:var(--text-secondary);text-align:right;margin-top:4px;">${d.views_this_week} views this week</div>
</div>` : '';
// ── Countries (each row clickable) ──────────────────
let countriesHtml = '';
if (d.countries && d.countries.length) {
countriesHtml = d.countries.map(c => `
<div class="ins-country-row" onclick="openCountryDetail('${c.code}','${c.name}',${c.count})" title="View ${c.name||c.code} details">
<div class="ins-country-flag">${_cflag(c.code)}</div>
<div class="ins-country-name">${c.name||c.code}</div>
<div class="ins-country-bar-wrap"><div class="ins-country-bar" style="width:${c.pct}%;"></div></div>
<div class="ins-country-pct">${c.pct}%</div>
<div class="ins-country-cnt">${_fmt(c.count)}</div>
<i class="bi bi-chevron-right" style="font-size:10px;color:var(--text-secondary);flex-shrink:0;"></i>
</div>`).join('');
}
// ── Viewers section ─────────────────────────────────
const topViewerRows = (d.top_viewers || []).map((u, i) => `
<div class="ins-dl-user-row" onclick="window.location.href='/channel/${u.channel||u.id}'" title="View ${u.name}'s profile">
<div style="font-size:12px;font-weight:700;color:var(--text-secondary);min-width:18px;text-align:center;">${i+1}</div>
<img src="${u.avatar}" alt="${u.name}" class="ins-dl-avatar">
<div style="flex:1;min-width:0;">
<div class="ins-dl-user-name">${u.name}</div>
<div class="ins-dl-user-meta">${_uaInline(u)} · last seen ${_ago(u.last_at)}</div>
</div>
<div class="ins-dl-count-badge">👁 ${u.count}×</div>
<i class="bi bi-chevron-right" style="font-size:10px;color:var(--text-secondary);flex-shrink:0;margin-left:4px;"></i>
</div>`).join('');
const recentViewerRows = (d.recent_viewers || []).map(r => {
const avatarSrc = r.user_avatar || `https://i.pravatar.cc/150?u=guest-${r.country||'unknown'}`;
const title = `Tap for full activity ${r.user_name}`;
const safeName = (r.user_name || 'Viewer').replace(/'/g, "\\'");
return `<div class="ins-dl-user-row" title="${title}"
onclick="openViewerDetail('${r.key}','${safeName}','${avatarSrc}')">
<img src="${avatarSrc}" alt="${r.user_name}" class="ins-dl-avatar">
<div style="flex:1;min-width:0;">
<div class="ins-dl-user-name">${r.user_name} ${_cflag(r.country)}</div>
<div class="ins-dl-user-meta">${_uaInline(r)} · ${_ago(r.last_at)}</div>
</div>
<div class="ins-dl-count-badge">👁 ${r.count}×</div>
<i class="bi bi-chevron-right" style="font-size:10px;color:var(--text-secondary);flex-shrink:0;margin-left:4px;"></i>
</div>`;
}).join('');
// Build the Viewers section only if there is something to show on either side
const hasTopViewers = !!topViewerRows;
const hasRecentViewers = !!recentViewerRows;
let viewersHtml = '';
if (hasTopViewers || hasRecentViewers) {
const topCol = hasTopViewers ? `
<div>
<div style="font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--text-secondary);margin-bottom:8px;">Top Viewers</div>
${topViewerRows}
</div>` : '';
const recentCol = hasRecentViewers ? `
<div>
<div style="font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--text-secondary);margin-bottom:8px;">
Recent Activity ${ (d.recent_viewers||[]).length > 10 ? `<span style="font-weight:400;opacity:.55;">(showing 10 — scroll for more)</span>` : '' }
</div>
<div class="ins-recent-scroll">${recentViewerRows}</div>
</div>` : '';
// Use single-column layout when only one side has data
const wrap = (hasTopViewers && hasRecentViewers) ? 'ins-two-col' : '';
viewersHtml = `
<div style="margin-top:20px;border-top:1px solid var(--border-color);padding-top:18px;">
<div class="ins-dl-header">
<div class="ins-section-title" style="margin:0;"><i class="bi bi-people-fill"></i> Viewers ${_fmt(d.unique_viewers)} registered · ${_fmt(d.guest_views||0)} guest</div>
</div>
<div class="${wrap}">${topCol}${recentCol}</div>
</div>`;
}
// ── Who Liked section ───────────────────────────────
let likersHtml = '';
if (d.likes > 0 && d.likers && d.likers.length) {
const likerRows = d.likers.map(u => `
<div class="ins-liker-row" onclick="window.location.href='/channel/${u.channel||u.id}'" title="View ${u.name}'s profile">
<img src="${u.avatar}" alt="${u.name}" class="ins-liker-avatar">
<div class="ins-liker-name">${u.name}</div>
<div class="ins-liker-time">${_ago(u.liked_at)}</div>
<i class="bi bi-chevron-right" style="font-size:10px;color:var(--text-secondary);flex-shrink:0;margin-left:4px;"></i>
</div>`).join('');
likersHtml = `
<div style="margin-top:20px;border-top:1px solid var(--border-color);padding-top:18px;">
<div class="ins-section-title"><i class="bi bi-heart-fill" style="color:#ef4444;"></i> Liked by — ${_fmt(d.likes)} ${d.likes===1?'person':'people'}</div>
${likerRows}
</div>`;
}
// (When d.likes === 0 the section is omitted entirely.)
// ── Downloads section ───────────────────────────────
let dlHtml = '';
if (d.downloads > 0) {
const pills = [
d.dl_video ? `<span class="ins-dl-pill video"><i class="bi bi-film"></i> ${_fmt(d.dl_video)} video</span>` : '',
d.dl_mp3 ? `<span class="ins-dl-pill mp3"><i class="bi bi-music-note"></i> ${_fmt(d.dl_mp3)} MP3</span>` : '',
d.dl_guests ? `<span class="ins-dl-pill guest"><i class="bi bi-person"></i> ${_fmt(d.dl_guests)} guest</span>` : '',
].filter(Boolean).join('');
const usersRows = (d.dl_users && d.dl_users.length)
? d.dl_users.map((u,i) => `
<div class="ins-dl-user-row" onclick="openDownloaderHistory(${u.id},'${u.name.replace(/'/g,"\\'")}','${u.avatar}')" title="See full download history">
<div style="font-size:12px;font-weight:700;color:var(--text-secondary);min-width:18px;text-align:center;">${i+1}</div>
<img src="${u.avatar}" alt="${u.name}" class="ins-dl-avatar">
<div style="flex:1;min-width:0;">
<div class="ins-dl-user-name">${u.name}</div>
<div class="ins-dl-user-meta">${_uaInline(u)} · last ${_ago(u.last_at)}</div>
</div>
<div class="ins-dl-count-badge">⬇️ ${u.count}×</div>
<i class="bi bi-chevron-right" style="font-size:10px;color:var(--text-secondary);flex-shrink:0;margin-left:4px;"></i>
</div>`).join('')
: '';
const recentRows = (d.dl_recent||[]).map(r => {
const avatarSrc = r.user_avatar || `https://i.pravatar.cc/150?u=guest-${r.country||'unknown'}`;
const safeName = (r.user_name || 'Downloader').replace(/'/g, "\\'");
const clickAttr = r.user_id
? `onclick="openDownloaderHistory(${r.user_id},'${safeName}','${avatarSrc}')" style="cursor:pointer;" title="See full download history"`
: `style="cursor:default;"`;
const typePill = `<span class="ins-recent-type ${r.type}" style="margin-right:4px;">${r.type.toUpperCase()}</span>`;
return `<div class="ins-dl-user-row" ${clickAttr}>
<img src="${avatarSrc}" alt="${r.user_name}" class="ins-dl-avatar">
<div style="flex:1;min-width:0;">
<div class="ins-dl-user-name">${r.user_name} ${_cflag(r.country)}</div>
<div class="ins-dl-user-meta">${typePill}${_uaInline(r)} · ${_ago(r.last_at)}</div>
</div>
<div class="ins-dl-count-badge">⬇️ ${r.count}×</div>
${r.user_id ? '<i class="bi bi-chevron-right" style="font-size:10px;color:var(--text-secondary);flex-shrink:0;margin-left:4px;"></i>' : ''}
</div>`;
}).join('');
const topCol = usersRows ? `
<div>
<div style="font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--text-secondary);margin-bottom:8px;">
Top Downloaders <span style="font-weight:400;opacity:.5;">(tap to see history)</span>
</div>
${usersRows}
</div>` : '';
const recentCol = recentRows ? `
<div>
<div style="font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--text-secondary);margin-bottom:8px;">
Recent Activity ${ (d.dl_recent||[]).length > 10 ? `<span style="font-weight:400;opacity:.55;">(showing 10 — scroll for more)</span>` : '' }
</div>
<div class="ins-recent-scroll">${recentRows}</div>
</div>` : '';
const dlWrap = (usersRows && recentRows) ? 'ins-two-col' : '';
dlHtml = `
<div style="margin-top:20px;border-top:1px solid var(--border-color);padding-top:18px;">
<div class="ins-dl-header">
<div class="ins-section-title" style="margin:0;"><i class="bi bi-download"></i> Downloads ${_fmt(d.downloads)} total</div>
<div class="ins-dl-type-pills">${pills}</div>
</div>
<div class="${dlWrap}">${topCol}${recentCol}</div>
</div>`;
}
// ── Age group breakdown (segmented by gender: blue=male, pink=female) ──
let ageHtml = '';
if (d.age_groups && d.age_groups.length > 0) {
const maxAge = Math.max(...d.age_groups.map(g => g.count), 1);
ageHtml = d.age_groups.map(g => {
const barW = Math.round(g.count / maxAge * 100);
const t = g.count || 1;
const mPct = Math.round(g.male / t * 100);
const fPct = Math.round(g.female / t * 100);
const oPct = Math.max(0, 100 - mPct - fPct);
const tip = `${g.label}: ${g.count} viewers · ♂ ${g.male} · ♀ ${g.female}${g.other ? ' · other ' + g.other : ''}`;
return `<div class="ins-demo-row" title="${tip}">
<span class="ins-demo-label ins-age-label">${g.label}</span>
<div class="ins-demo-bar-wrap">
<div style="display:flex;height:100%;width:${barW}%;border-radius:4px;overflow:hidden;">
<div style="width:${mPct}%;background:#4a9eff;" title="Male: ${g.male}"></div>
<div style="width:${fPct}%;background:#ff6eb0;" title="Female: ${g.female}"></div>
${oPct > 0 ? `<div style="width:${oPct}%;background:#facc15;" title="Other: ${g.other}"></div>` : ''}
</div>
</div>
<span class="ins-demo-pct">${g.pct}%</span>
<span class="ins-demo-cnt">${_fmt(g.count)}</span>
</div>`;
}).join('');
}
const ageLegend = `<div class="ins-bar-legend" style="margin-top:10px;">
<span><i style="background:#4a9eff;"></i> Male</span>
<span><i style="background:#ff6eb0;"></i> Female</span>
</div>`;
// Demographics — only when at least one bucket has data
const demographicsHtml = ageHtml ? `
<div style="margin-top:22px;">
<div class="ins-section-title"><i class="bi bi-people-fill"></i> Viewers by Age Group</div>
${ageHtml}
${ageLegend}
</div>` : '';
// ── Share links breakdown ───────────────────────────
let shareHtml = '';
if (d.share_links > 0) {
const linkRows = (d.share_breakdown || []).map((s, i) => {
const avatarHtml = s.avatar
? `<img src="${s.avatar}" class="ins-dl-avatar" alt="${s.sharer}">`
: `<div class="ins-dl-avatar" style="display:flex;align-items:center;justify-content:center;font-size:16px;">👤</div>`;
const safeName = (s.sharer || 'Sharer').replace(/'/g, "\\'");
const clickAttr = `onclick="openShareDetail('${s.token}','${safeName}')" style="cursor:pointer;" title="See who came from this link"`;
const flagHtml = s.country
? `<span style="flex-shrink:0;" title="${s.country_name||s.country}">${_cflag(s.country)}</span>`
: '';
return `<div class="ins-dl-user-row" ${clickAttr}>
<div style="font-size:12px;font-weight:700;color:var(--text-secondary);min-width:18px;text-align:center;">${i+1}</div>
${avatarHtml}
<div style="flex:1;min-width:0;">
<div class="ins-dl-user-name">${s.sharer}</div>
<div class="ins-dl-user-meta">${_fmtDt(s.created_at)} · ${_ago(s.created_at)}</div>
</div>
${flagHtml}
<div class="ins-dl-count-badge" title="Unique devices that opened this link">👁️ ${_fmt(s.reach)}</div>
<i class="bi bi-chevron-right" style="font-size:10px;color:var(--text-secondary);flex-shrink:0;margin-left:4px;"></i>
</div>`;
}).join('');
shareHtml = `
<div style="margin-top:20px;border-top:1px solid var(--border-color);padding-top:18px;">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:12px;flex-wrap:wrap;gap:8px;">
<div class="ins-section-title" style="margin:0;"><i class="bi bi-share"></i> Share Links — ${_fmt(d.share_links)} created · ${_fmt(d.shares)} unique devices reached</div>
</div>
<p style="font-size:11px;color:var(--text-secondary);margin:0 0 10px;line-height:1.5;">Each sharer gets a unique link. The same device opening the same link is counted only once — VPN IP changes don't affect the count.</p>
${linkRows}
</div>`;
}
// (When d.share_links === 0 the section is omitted entirely.)
// Left column: chart + demographics. Right column: country audience.
const leftCol = chartSection + demographicsHtml;
const rightCol = countriesHtml ? `
<div>
<div class="ins-section-title"><i class="bi bi-globe2"></i> Audience by Country <span style="font-size:10px;opacity:.5;font-weight:400;text-transform:none;">(tap for viewer breakdown)</span></div>
${countriesHtml}
</div>` : '';
let topBlock = '';
if (leftCol && rightCol) {
topBlock = `<div class="ins-body"><div>${leftCol}</div>${rightCol}</div>`;
} else if (leftCol) {
topBlock = `<div>${leftCol}</div>`;
} else if (rightCol) {
topBlock = `<div>${rightCol}</div>`;
}
// Pair likes + shares side-by-side only when both have data;
// otherwise emit each on its own row so empty ones simply disappear.
let likesShareBlock = '';
if (likersHtml && shareHtml) {
likesShareBlock = `<div class="ins-two-col" style="margin-top:0;">
<div>${likersHtml}</div>
<div>${shareHtml}</div>
</div>`;
} else {
likesShareBlock = (likersHtml || '') + (shareHtml || '');
}
document.getElementById('insightsContent').innerHTML =
cards + topBlock + viewersHtml + dlHtml + likesShareBlock;
}
// ══════════════════════════════════════════════════════
// STAT CARD MODALS (top-level summaries)
// ══════════════════════════════════════════════════════
function openInsModal_views() {
const d = window._insData; if(!d) return;
const changeColor = d.week_change>0?'#4ade80':d.week_change<0?'#f87171':'var(--text-secondary)';
const changeLabel = d.week_change>0?`↑ ${d.week_change}%`:d.week_change<0?`↓ ${Math.abs(d.week_change)}%`:'— 0%';
_openModal('👁️','View Analytics','All-time stats',`
<div class="ins-modal-hero">
<div class="ins-modal-hero-num">${_fmt(d.total_views)}</div>
<div class="ins-modal-hero-label">total views</div>
</div>
<div class="ins-modal-stat"><span class="ins-modal-stat-lbl"><i class="bi bi-calendar-day"></i> Today</span><span class="ins-modal-stat-val">${_fmt(d.views_today)}</span></div>
<div class="ins-modal-stat"><span class="ins-modal-stat-lbl"><i class="bi bi-calendar-week"></i> This week</span><span class="ins-modal-stat-val">${_fmt(d.views_this_week)}</span></div>
<div class="ins-modal-stat"><span class="ins-modal-stat-lbl"><i class="bi bi-calendar2-minus"></i> Last week</span><span class="ins-modal-stat-val">${_fmt(d.views_last_week)}</span></div>
<div class="ins-modal-stat"><span class="ins-modal-stat-lbl"><i class="bi bi-graph-up-arrow"></i> Week over week</span><span class="ins-modal-stat-val" style="color:${changeColor};">${changeLabel}</span></div>
<div class="ins-modal-stat"><span class="ins-modal-stat-lbl"><i class="bi bi-skip-forward-fill" style="color:#f59e0b;"></i> Skipped early</span><span class="ins-modal-stat-val">${_fmt(d.skipped_views||0)} <span style="color:var(--text-secondary);font-weight:400;">(${d.skip_rate||0}% of views)</span></span></div>
<div class="ins-modal-stat" style="border-bottom:none;margin-bottom:18px;"><span class="ins-modal-stat-lbl"><i class="bi bi-stopwatch"></i> Skip threshold</span><span class="ins-modal-stat-val" style="font-weight:500;color:var(--text-secondary);">under ${d.skip_threshold||10}s watched</span></div>
<div class="ins-modal-section"><i class="bi bi-graph-up"></i> Last 14 days tap a bar to see that day</div>
<div class="ins-chart" style="height:72px;">${_segmentedBars(window._insData.daily, 68, {
onClick: `closeInsModal();setTimeout(()=>openDayDetail('__DATE__','__LABEL__',__COUNT__),180)`
})}</div>
${_segLegendHtml}
${d.peak_hour!==null?`<div style="margin-top:10px;"><span class="ins-peak-badge"><i class="bi bi-clock-fill"></i> Peak hour: ${_fmtH(d.peak_hour)}</span></div>`:''}`);
}
function openInsModal_viewers() {
const d = window._insData; if(!d) return;
const guestV = Math.max(0, d.total_views - d.unique_viewers);
const mPct = d.total_views>0 ? Math.round(d.unique_viewers/d.total_views*100) : 0;
_openModal('👤','Viewer Breakdown','Registered vs anonymous',`
<div class="ins-modal-hero">
<div class="ins-modal-hero-num">${_fmt(d.unique_viewers)}</div>
<div class="ins-modal-hero-label">unique registered viewers</div>
</div>
<div class="ins-modal-stat"><span class="ins-modal-stat-lbl"><i class="bi bi-eye"></i> Total views</span><span class="ins-modal-stat-val">${_fmt(d.total_views)}</span></div>
<div class="ins-modal-stat"><span class="ins-modal-stat-lbl"><i class="bi bi-person-check-fill" style="color:#4ade80;"></i> Registered</span><span class="ins-modal-stat-val">${mPct}% <span style="color:var(--text-secondary);font-weight:400;">(${_fmt(d.unique_viewers)} views)</span></span></div>
<div class="ins-modal-stat" style="border-bottom:none;margin-bottom:18px;"><span class="ins-modal-stat-lbl"><i class="bi bi-person-dash"></i> Anonymous</span><span class="ins-modal-stat-val">${100-mPct}% <span style="color:var(--text-secondary);font-weight:400;">(${_fmt(guestV)} views)</span></span></div>
<div class="ins-modal-section"><i class="bi bi-globe2"></i> Top countries tap to see viewers</div>
${(d.countries||[]).slice(0,5).map(c=>`
<div class="ins-country-row" onclick="closeInsModal();setTimeout(()=>openCountryDetail('${c.code}','${c.name}',${c.count}),180)" style="cursor:pointer;">
<div class="ins-country-flag">${_cflag(c.code)}</div>
<div class="ins-country-name">${c.name||c.code}</div>
<div class="ins-country-bar-wrap"><div class="ins-country-bar" style="width:${c.pct}%;"></div></div>
<div class="ins-country-pct">${c.pct}%</div>
<div class="ins-country-cnt">${_fmt(c.count)}</div>
<i class="bi bi-chevron-right" style="font-size:10px;color:var(--text-secondary);"></i>
</div>`).join('')||'<p style="font-size:13px;color:var(--text-secondary);margin:0;">No location data.</p>'}`);
}
function openInsModal_downloads() {
const d = window._insData; if(!d) return;
if (d.downloads===0) {
_openModal('⬇️','Downloads','No downloads yet',`<div style="text-align:center;padding:30px 0;"><div style="font-size:48px;margin-bottom:12px;">📭</div><p style="color:var(--text-secondary);font-size:14px;margin:0;">No one has downloaded this video yet.</p></div>`);
return;
}
const engRate = d.total_views>0 ? ((d.downloads/d.total_views)*100).toFixed(1) : '0.0';
_openModal('⬇️','Download Summary',`${_fmt(d.downloads)} total downloads`,`
<div class="ins-modal-hero">
<div class="ins-modal-hero-num">${_fmt(d.downloads)}</div>
<div class="ins-modal-hero-label">total downloads</div>
</div>
<div style="display:flex;gap:8px;margin-bottom:18px;flex-wrap:wrap;">
${d.dl_video ? `<span class="ins-dl-pill video"><i class="bi bi-film"></i> ${_fmt(d.dl_video)} video</span>` : ''}
${d.dl_mp3 ? `<span class="ins-dl-pill mp3"><i class="bi bi-music-note"></i> ${_fmt(d.dl_mp3)} MP3</span>` : ''}
${d.dl_guests ? `<span class="ins-dl-pill guest"><i class="bi bi-person"></i> ${_fmt(d.dl_guests)} guest</span>` : ''}
</div>
<div class="ins-modal-stat"><span class="ins-modal-stat-lbl"><i class="bi bi-eye"></i> Download rate</span><span class="ins-modal-stat-val">${engRate}% of viewers</span></div>
<div class="ins-modal-stat" style="border-bottom:none;margin-bottom:18px;"><span class="ins-modal-stat-lbl"><i class="bi bi-people"></i> Unique downloaders</span><span class="ins-modal-stat-val">${_fmt((d.dl_users||[]).length + (d.dl_guests>0?1:0))}</span></div>
${(d.dl_users||[]).length ? `
<div class="ins-modal-section"><i class="bi bi-person-check-fill" style="color:#4ade80;"></i> Registered — tap for full history</div>
${d.dl_users.map((u,i)=>`
<div class="ins-person-row" style="cursor:pointer;" onclick="closeInsModal();setTimeout(()=>openDownloaderHistory(${u.id},'${u.name.replace(/'/g,"\\'")}','${u.avatar}'),180)">
<div style="font-size:11px;font-weight:700;color:var(--text-secondary);min-width:18px;text-align:center;">${i+1}</div>
<img src="${u.avatar}" class="ins-person-avatar" alt="${u.name}">
<div style="flex:1;min-width:0;"><div class="ins-person-name">${u.name}</div><div class="ins-person-meta">Last: ${_ago(u.last_at)}</div></div>
<div class="ins-person-badge">⬇️ ${u.count}×</div>
<i class="bi bi-chevron-right" style="font-size:10px;color:var(--text-secondary);margin-left:6px;flex-shrink:0;"></i>
</div>`).join('')}` : ''}
`);
}
function openInsModal_shares() {
const d = window._insData; if(!d) return;
const engRate = d.total_views>0 ? (((d.shares)+d.likes)/d.total_views*100).toFixed(1) : '0.0';
const linkRows = (d.share_breakdown||[]).length
? (d.share_breakdown||[]).map((s,i) => {
const avatarHtml = s.avatar
? `<img src="${s.avatar}" class="ins-dl-avatar" alt="${s.sharer}">`
: `<div class="ins-dl-avatar" style="display:flex;align-items:center;justify-content:center;font-size:16px;">👤</div>`;
const safeName = (s.sharer || 'Sharer').replace(/'/g, "\\'");
const clickAttr = `onclick="closeInsModal();setTimeout(()=>openShareDetail('${s.token}','${safeName}'),180)" style="cursor:pointer;" title="See who came from this link"`;
const flagHtml = s.country
? `<span style="flex-shrink:0;" title="${s.country_name||s.country}">${_cflag(s.country)}</span>`
: '';
return `<div class="ins-dl-user-row" ${clickAttr}>
<div style="font-size:12px;font-weight:700;color:var(--text-secondary);min-width:18px;text-align:center;">${i+1}</div>
${avatarHtml}
<div style="flex:1;min-width:0;">
<div class="ins-dl-user-name">${s.sharer}</div>
<div class="ins-dl-user-meta">${_fmtDt(s.created_at)} · ${_ago(s.created_at)}</div>
</div>
${flagHtml}
<div class="ins-dl-count-badge">👁️ ${_fmt(s.reach)}</div>
<i class="bi bi-chevron-right" style="font-size:10px;color:var(--text-secondary);flex-shrink:0;margin-left:4px;"></i>
</div>`;
}).join('')
: '<p style="font-size:13px;color:var(--text-secondary);margin:0;">No share links created yet.</p>';
_openModal('🔗','Share Reach','Unique devices that opened your share links',`
<div class="ins-modal-hero">
<div class="ins-modal-hero-num">${_fmt(d.shares)}</div>
<div class="ins-modal-hero-label">unique devices reached</div>
</div>
<div class="ins-modal-stat"><span class="ins-modal-stat-lbl"><i class="bi bi-link-45deg"></i> Links created</span><span class="ins-modal-stat-val">${_fmt(d.share_links)}</span></div>
<div class="ins-modal-stat"><span class="ins-modal-stat-lbl"><i class="bi bi-hand-thumbs-up-fill" style="color:#ef4444;"></i> Likes</span><span class="ins-modal-stat-val">❤️ ${_fmt(d.likes)}</span></div>
<div class="ins-modal-stat"><span class="ins-modal-stat-lbl"><i class="bi bi-activity"></i> Engagement rate</span><span class="ins-modal-stat-val">${engRate}%</span></div>
<div class="ins-modal-section" style="margin-top:16px;"><i class="bi bi-people-fill"></i> Share links top by reach</div>
${linkRows}
<p style="font-size:11px;color:var(--text-secondary);margin:12px 0 0;line-height:1.5;">Each person who shares gets a unique link. Same device opening the same link counts only once.</p>`);
}
function openInsModal_likes() {
const d = window._insData; if(!d) return;
if (d.likes === 0) {
_openModal('❤️','Likes','No likes yet',`<div style="text-align:center;padding:30px 0;"><div style="font-size:48px;margin-bottom:12px;">🤍</div><p style="color:var(--text-secondary);font-size:14px;margin:0;">No one has liked this video yet.</p></div>`);
return;
}
const likeRate = d.total_views>0 ? ((d.likes/d.total_views)*100).toFixed(1) : '0.0';
const likerRows = (d.likers||[]).map((u,i) => `
<div class="ins-person-row" onclick="window.location.href='/channel/${u.channel||u.id}'" title="View ${u.name}'s profile">
<div style="font-size:11px;font-weight:700;color:var(--text-secondary);min-width:18px;text-align:center;">${i+1}</div>
<img src="${u.avatar}" class="ins-person-avatar" alt="${u.name}">
<div style="flex:1;min-width:0;">
<div class="ins-person-name">${u.name}</div>
<div class="ins-person-meta">${_ago(u.liked_at)}</div>
</div>
<i class="bi bi-chevron-right" style="font-size:10px;color:var(--text-secondary);flex-shrink:0;"></i>
</div>`).join('');
_openModal('❤️','Likes',`${_fmt(d.likes)} ${d.likes===1?'person':'people'} liked this`,`
<div class="ins-modal-hero">
<div class="ins-modal-hero-num">${_fmt(d.likes)}</div>
<div class="ins-modal-hero-label">${d.likes===1?'person':'people'} liked this video</div>
</div>
<div class="ins-modal-stat"><span class="ins-modal-stat-lbl"><i class="bi bi-eye"></i> Like rate</span><span class="ins-modal-stat-val">${likeRate}% of viewers</span></div>
<div class="ins-modal-stat" style="border-bottom:none;margin-bottom:18px;"><span class="ins-modal-stat-lbl"><i class="bi bi-eye"></i> Total views</span><span class="ins-modal-stat-val">${_fmt(d.total_views)}</span></div>
<div class="ins-modal-section"><i class="bi bi-heart-fill" style="color:#ef4444;"></i> Who liked</div>
${likerRows || '<p style="font-size:13px;color:var(--text-secondary);margin:0;">No data available.</p>'}`);
}
function openInsModal_reach() {
const d = window._insData; if(!d) return;
const total = d.accounts_reached || 0;
const usersPct = total>0 ? Math.round((d.reached_users||0)/total*100) : 0;
const guestsPct = total>0 ? 100 - usersPct : 0;
const repeatRatio = total>0 ? (d.total_views/total).toFixed(1) : '0.0';
const countryRows = (d.countries||[]).slice(0,8).map(c=>`
<div class="ins-country-row" onclick="closeInsModal();setTimeout(()=>openCountryDetail('${c.code}','${c.name}',${c.count}),180)" style="cursor:pointer;">
<div class="ins-country-flag">${_cflag(c.code)}</div>
<div class="ins-country-name">${c.name||c.code}</div>
<div class="ins-country-bar-wrap"><div class="ins-country-bar" style="width:${c.pct}%;"></div></div>
<div class="ins-country-pct">${c.pct}%</div>
<div class="ins-country-cnt">${_fmt(c.count)}</div>
<i class="bi bi-chevron-right" style="font-size:10px;color:var(--text-secondary);"></i>
</div>`).join('');
_openModal('📡','Reach','Distinct people who saw this video',`
<div class="ins-modal-hero">
<div class="ins-modal-hero-num">${_fmt(total)}</div>
<div class="ins-modal-hero-label">accounts reached</div>
</div>
<div class="ins-modal-stat"><span class="ins-modal-stat-lbl"><i class="bi bi-person-check-fill" style="color:#4ade80;"></i> Signed-in</span><span class="ins-modal-stat-val">${_fmt(d.reached_users||0)} <span style="color:var(--text-secondary);font-weight:400;">(${usersPct}%)</span></span></div>
<div class="ins-modal-stat"><span class="ins-modal-stat-lbl"><i class="bi bi-person"></i> Guest devices</span><span class="ins-modal-stat-val">${_fmt(d.reached_guests||0)} <span style="color:var(--text-secondary);font-weight:400;">(${guestsPct}%)</span></span></div>
<div class="ins-modal-stat"><span class="ins-modal-stat-lbl"><i class="bi bi-eye"></i> Total views</span><span class="ins-modal-stat-val">${_fmt(d.total_views)}</span></div>
<div class="ins-modal-stat" style="border-bottom:none;margin-bottom:18px;"><span class="ins-modal-stat-lbl"><i class="bi bi-arrow-repeat"></i> Avg views per account</span><span class="ins-modal-stat-val">${repeatRatio}×</span></div>
${countryRows ? `<div class="ins-modal-section"><i class="bi bi-globe2"></i> Top countries — tap for viewers</div>${countryRows}` : ''}`);
}
function openInsModal_saves() {
const d = window._insData; if(!d) return;
if ((d.save_count||0) === 0) {
_openModal('🔖','Saves','Not saved yet',`<div style="text-align:center;padding:30px 0;"><div style="font-size:48px;margin-bottom:12px;">📭</div><p style="color:var(--text-secondary);font-size:14px;margin:0;">No viewer has added this video to a playlist yet.</p></div>`);
return;
}
_openModal('🔖','Saves','Viewers who added this to a playlist',`
<div class="ins-modal-hero">
<div class="ins-modal-hero-num">${_fmt(d.save_count||0)}</div>
<div class="ins-modal-hero-label">${d.save_count===1?'person':'people'} saved this video</div>
</div>
<div class="ins-modal-stat"><span class="ins-modal-stat-lbl"><i class="bi bi-percent"></i> Save rate</span><span class="ins-modal-stat-val">${d.save_rate||0}% <span style="color:var(--text-secondary);font-weight:400;">of unique viewers</span></span></div>
<div class="ins-modal-stat"><span class="ins-modal-stat-lbl"><i class="bi bi-person-check"></i> Unique viewers</span><span class="ins-modal-stat-val">${_fmt(d.unique_viewers||0)}</span></div>
<div class="ins-modal-stat" style="border-bottom:none;margin-bottom:0;"><span class="ins-modal-stat-lbl"><i class="bi bi-info-circle"></i> Note</span><span class="ins-modal-stat-val" style="font-weight:500;color:var(--text-secondary);font-size:12px;">your own playlists excluded</span></div>`);
}
function openInsModal_comments() {
const d = window._insData; if(!d) return;
if ((d.comments_count||0) === 0) {
_openModal('💬','Comments','No comments yet',`<div style="text-align:center;padding:30px 0;"><div style="font-size:48px;margin-bottom:12px;">💭</div><p style="color:var(--text-secondary);font-size:14px;margin:0;">No comments on this video yet.</p></div>`);
return;
}
const commentRate = d.total_views>0 ? ((d.comments_count/d.total_views)*100).toFixed(1) : '0.0';
_openModal('💬','Comments','Conversation on this video',`
<div class="ins-modal-hero">
<div class="ins-modal-hero-num">${_fmt(d.comments_count||0)}</div>
<div class="ins-modal-hero-label">comments &amp; replies</div>
</div>
<div class="ins-modal-stat"><span class="ins-modal-stat-lbl"><i class="bi bi-percent"></i> Comment rate</span><span class="ins-modal-stat-val">${commentRate}% <span style="color:var(--text-secondary);font-weight:400;">of views</span></span></div>
<div class="ins-modal-stat" style="border-bottom:none;margin-bottom:14px;"><span class="ins-modal-stat-lbl"><i class="bi bi-eye"></i> Total views</span><span class="ins-modal-stat-val">${_fmt(d.total_views)}</span></div>
<div style="text-align:center;padding:10px 0 4px;">
<button class="action-btn" onclick="closeInsModal();setTimeout(()=>{var el=document.getElementById('commentsSection')||document.querySelector('.comments-section, [data-comments]');if(el)el.scrollIntoView({behavior:'smooth',block:'start'});},200);">
<i class="bi bi-chat-dots"></i> <span>Jump to comments</span>
</button>
</div>`);
}
function openInsModal_conversions() {
const d = window._insData; if(!d) return;
const total = (d.shares||0) + (d.profile_visits||0) + (d.new_subscribers||0);
if (total === 0) {
_openModal('🚀','Conversions','No conversions yet',`<div style="text-align:center;padding:30px 0;"><div style="font-size:48px;margin-bottom:12px;">🌱</div><p style="color:var(--text-secondary);font-size:14px;margin:0;">No shares, wall visits, or new subscribers driven by this video yet.</p></div>`);
return;
}
const subRate = d.total_views>0 ? ((d.new_subscribers||0)/d.total_views*100).toFixed(1) : '0.0';
const visitRate = d.total_views>0 ? ((d.profile_visits||0)/d.total_views*100).toFixed(1) : '0.0';
// Share-link breakdown reuses the existing shares modal layout
const linkRows = (d.share_breakdown||[]).slice(0,5).map((s,i) => {
const avatarHtml = s.avatar
? `<img src="${s.avatar}" class="ins-dl-avatar" alt="${s.sharer}">`
: `<div class="ins-dl-avatar" style="display:flex;align-items:center;justify-content:center;font-size:16px;">👤</div>`;
const safeName = (s.sharer || 'Sharer').replace(/'/g, "\\'");
const flagHtml = s.country ? `<span style="flex-shrink:0;" title="${s.country_name||s.country}">${_cflag(s.country)}</span>` : '';
return `<div class="ins-dl-user-row" onclick="closeInsModal();setTimeout(()=>openShareDetail('${s.token}','${safeName}'),180)" style="cursor:pointer;">
<div style="font-size:12px;font-weight:700;color:var(--text-secondary);min-width:18px;text-align:center;">${i+1}</div>
${avatarHtml}
<div style="flex:1;min-width:0;">
<div class="ins-dl-user-name">${s.sharer}</div>
<div class="ins-dl-user-meta">${_ago(s.created_at)}</div>
</div>
${flagHtml}
<div class="ins-dl-count-badge">👁️ ${_fmt(s.reach)}</div>
<i class="bi bi-chevron-right" style="font-size:10px;color:var(--text-secondary);flex-shrink:0;margin-left:4px;"></i>
</div>`;
}).join('');
_openModal('🚀','Conversions','Downstream actions this video drove',`
<div class="ins-modal-hero">
<div class="ins-modal-hero-num">${_fmt(total)}</div>
<div class="ins-modal-hero-label">total conversions</div>
</div>
<div class="ins-modal-stat"><span class="ins-modal-stat-lbl"><i class="bi bi-link-45deg" style="color:#60a5fa;"></i> Share reach</span><span class="ins-modal-stat-val">${_fmt(d.shares||0)} <span style="color:var(--text-secondary);font-weight:400;">(${_fmt(d.share_links||0)} link${d.share_links===1?'':'s'})</span></span></div>
<div class="ins-modal-stat"><span class="ins-modal-stat-lbl"><i class="bi bi-door-open" style="color:#f59e0b;"></i> Wall visits</span><span class="ins-modal-stat-val">${_fmt(d.profile_visits||0)} <span style="color:var(--text-secondary);font-weight:400;">(${visitRate}% of views)</span></span></div>
<div class="ins-modal-stat" style="border-bottom:none;margin-bottom:18px;"><span class="ins-modal-stat-lbl"><i class="bi bi-stars" style="color:#ef4444;"></i> New subscribers</span><span class="ins-modal-stat-val">${_fmt(d.new_subscribers||0)} <span style="color:var(--text-secondary);font-weight:400;">(${subRate}% of views)</span></span></div>
${linkRows ? `<div class="ins-modal-section"><i class="bi bi-people-fill"></i> Top share links — tap to see who came</div>${linkRows}` : ''}
<p style="font-size:11px;color:var(--text-secondary);margin:12px 0 0;line-height:1.5;">Wall visits and new subscribers are only counted when triggered directly from this video page.</p>`);
}
// ══════════════════════════════════════════════════════
// DRILL-DOWN: COUNTRY
// ══════════════════════════════════════════════════════
function openCountryDetail(code, name, totalCount) {
const flag = _cflag(code);
const baseUrl = document.getElementById('vdb-insights')?.dataset.insightsBase || '';
_modalLoading(flag, name||code, `${_fmt(totalCount)} views from this country`);
fetch(`${baseUrl}/country/${code}`, {
headers:{'Accept':'application/json','X-Requested-With':'XMLHttpRequest'}
})
.then(r=>{ if(!r.ok) throw r; return r.json(); })
.then(d => {
const regHtml = _personListHtml(d.registered_users, 'No registered viewers yet from this country.');
const miniChart = _miniBarChart(d.daily);
document.getElementById('insModalBody').innerHTML = `
<div class="ins-modal-hero">
<div class="ins-modal-hero-flag">${flag}</div>
<div class="ins-modal-hero-num">${_fmt(d.total_views)}</div>
<div class="ins-modal-hero-label">total views from ${d.country_name||code}</div>
</div>
<div class="ins-modal-stat">
<span class="ins-modal-stat-lbl"><i class="bi bi-person-check-fill" style="color:#4ade80;"></i> Registered viewers</span>
<span class="ins-modal-stat-val">${_fmt((d.registered_users||[]).length)}</span>
</div>
<div class="ins-modal-stat" style="border-bottom:none;margin-bottom:18px;">
<span class="ins-modal-stat-lbl"><i class="bi bi-person-dash"></i> Anonymous views</span>
<span class="ins-modal-stat-val">${_fmt(d.guest_count)}</span>
</div>
<div class="ins-modal-section"><i class="bi bi-graph-up"></i> 14-day trend from ${d.country_name||code}</div>
<div class="ins-chart" style="height:60px;">${miniChart}</div>
${(d.registered_users||[]).length ? `
<div class="ins-modal-section"><i class="bi bi-person-check-fill" style="color:#4ade80;"></i> Registered viewers</div>
${regHtml}` : `
<div class="ins-modal-section"><i class="bi bi-people"></i> Viewers</div>
<p style="font-size:13px;color:var(--text-secondary);margin:0;">
All ${_fmt(d.total_views)} views from ${d.country_name||code} were anonymous.
</p>`}`;
document.getElementById('insModalTitle').textContent = d.country_name || code;
document.getElementById('insModalSubtitle').textContent = `${_fmt(d.total_views)} views from this country`;
})
.catch(() => {
document.getElementById('insModalBody').innerHTML =
'<p style="color:var(--text-secondary);font-size:13px;padding:8px 0;">Could not load country details.</p>';
});
}
// ══════════════════════════════════════════════════════
// DRILL-DOWN: SPECIFIC DAY
// ══════════════════════════════════════════════════════
function openDayDetail(date, label, totalCount) {
const baseUrl = document.getElementById('vdb-insights')?.dataset.insightsBase || '';
_modalLoading('📅', label, `${_fmt(totalCount)} views on this day`);
fetch(`${baseUrl}/day/${date}`, {
headers:{'Accept':'application/json','X-Requested-With':'XMLHttpRequest'}
})
.then(r=>{ if(!r.ok) throw r; return r.json(); })
.then(d => {
const hourlyHtml = _hourlyChart(d.hourly);
const regHtml = _personListHtml(d.registered_users, 'No registered viewers on this day.');
const topCountries = (d.countries||[]).map(c=>`
<div class="ins-country-row" style="cursor:pointer;" onclick="closeInsModal();setTimeout(()=>openCountryDetail('${c.code}','${c.name}',${c.count}),180)">
<div class="ins-country-flag">${_cflag(c.code)}</div>
<div class="ins-country-name">${c.name||c.code}</div>
<div class="ins-country-cnt" style="margin-left:auto;">${_fmt(c.count)}</div>
<i class="bi bi-chevron-right" style="font-size:10px;color:var(--text-secondary);"></i>
</div>`).join('') || '<p style="font-size:13px;color:var(--text-secondary);margin:0;">No location data.</p>';
document.getElementById('insModalIcon').innerHTML = '📅';
document.getElementById('insModalTitle').textContent = `${d.day_of_week}, ${d.date}`;
document.getElementById('insModalSubtitle').textContent = `${_fmt(d.total_views)} views on this day`;
document.getElementById('insModalBody').innerHTML = `
<div class="ins-modal-hero">
<div class="ins-modal-hero-num">${_fmt(d.total_views)}</div>
<div class="ins-modal-hero-label">views on ${d.day_of_week}</div>
</div>
<div class="ins-modal-stat">
<span class="ins-modal-stat-lbl"><i class="bi bi-person-check-fill" style="color:#4ade80;"></i> Registered</span>
<span class="ins-modal-stat-val">${_fmt((d.registered_users||[]).length)} viewers (${_fmt(d.total_views - d.guest_count)} views)</span>
</div>
<div class="ins-modal-stat" style="border-bottom:none;margin-bottom:18px;">
<span class="ins-modal-stat-lbl"><i class="bi bi-person-dash"></i> Anonymous</span>
<span class="ins-modal-stat-val">${_fmt(d.guest_count)} views</span>
</div>
<div class="ins-modal-section"><i class="bi bi-clock"></i> Views by Hour</div>
${hourlyHtml}
${(d.registered_users||[]).length ? `
<div class="ins-modal-section" style="margin-top:18px;"><i class="bi bi-person-check-fill" style="color:#4ade80;"></i> Who watched</div>
${regHtml}` : ''}
<div class="ins-modal-section" style="margin-top:18px;"><i class="bi bi-globe2"></i> Top countries tap to explore</div>
${topCountries}`;
})
.catch(() => {
document.getElementById('insModalBody').innerHTML =
'<p style="color:var(--text-secondary);font-size:13px;padding:8px 0;">Could not load day details.</p>';
});
}
// ══════════════════════════════════════════════════════
// DRILL-DOWN: DOWNLOADER HISTORY
// ══════════════════════════════════════════════════════
function openDownloaderHistory(userId, userName, userAvatar) {
const baseUrl = document.getElementById('vdb-insights')?.dataset.insightsBase || '';
_modalLoading('⬇️', userName, 'Loading download history…');
document.getElementById('insModalIcon').innerHTML = `<img src="${userAvatar}" style="width:32px;height:32px;border-radius:50%;object-fit:cover;">`;
fetch(`${baseUrl}/downloader/${userId}`, {
headers:{'Accept':'application/json','X-Requested-With':'XMLHttpRequest'}
})
.then(r=>{ if(!r.ok) throw r; return r.json(); })
.then(d => {
document.getElementById('insModalIcon').innerHTML = `<img src="${d.user.avatar}" style="width:32px;height:32px;border-radius:50%;object-fit:cover;">`;
document.getElementById('insModalTitle').textContent = d.user.name;
document.getElementById('insModalSubtitle').textContent = `${d.total} download${d.total!==1?'s':''} of this video`;
const rows = d.records.map((r,i) => `
<div class="ins-dl-hist-row">
<div class="ins-dl-hist-num">${i+1}</div>
<span class="ins-dl-hist-type ${r.type}">${r.type.toUpperCase()}</span>
<div class="ins-dl-hist-time">${_fmtDt(r.at)}</div>
<div class="ins-dl-hist-flag" title="${r.country_name||r.country||'Unknown'}">${_cflag(r.country)}</div>
</div>`).join('');
document.getElementById('insModalBody').innerHTML = `
<div style="display:flex;align-items:center;gap:12px;padding:4px 0 20px;">
<img src="${d.user.avatar}" style="width:52px;height:52px;border-radius:50%;object-fit:cover;border:2px solid var(--border-color);">
<div>
<div style="font-size:16px;font-weight:700;color:var(--text-primary);">${d.user.name}</div>
<div style="font-size:13px;color:var(--text-secondary);margin-top:2px;">
${d.total} download${d.total!==1?'s':''} of this video
</div>
</div>
</div>
<div class="ins-modal-section"><i class="bi bi-clock-history"></i> Download History</div>
${rows || '<p style="font-size:13px;color:var(--text-secondary);margin:0;">No records found.</p>'}`;
})
.catch(() => {
document.getElementById('insModalBody').innerHTML =
'<p style="color:var(--text-secondary);font-size:13px;padding:8px 0;">Could not load download history.</p>';
});
}
// ══════════════════════════════════════════════════════
// DRILL-DOWN: SINGLE VIEWER (grouped activity popup)
// ══════════════════════════════════════════════════════
function _uaBucketsHtml(buckets, iconFor) {
if (!buckets || !buckets.length) return '<p style="font-size:13px;color:var(--text-secondary);margin:0;">No data.</p>';
const max = Math.max(...buckets.map(b => b.count), 1);
return buckets.map(b => {
const pct = Math.round((b.count / max) * 100);
return `<div class="ins-ua-row">
<span class="ins-ua-icon">${iconFor(b.label)}</span>
<span class="ins-ua-label">${b.label}</span>
<div class="ins-ua-bar-wrap"><div class="ins-ua-bar" style="width:${pct}%;"></div></div>
<span class="ins-ua-cnt">${b.count}</span>
</div>`;
}).join('');
}
function _iconForDevice(label) {
if (/iPhone|Android phone|Mobile/i.test(label)) return '<i class="bi bi-phone"></i>';
if (/iPad|Tablet/i.test(label)) return '<i class="bi bi-tablet"></i>';
if (/Desktop/i.test(label)) return '<i class="bi bi-display"></i>';
return '<i class="bi bi-question-circle"></i>';
}
function _iconForBrowser(label) {
if (/Chrome/i.test(label)) return '<i class="bi bi-browser-chrome"></i>';
if (/Firefox/i.test(label)) return '<i class="bi bi-browser-firefox"></i>';
if (/Safari/i.test(label)) return '<i class="bi bi-browser-safari"></i>';
if (/Edge/i.test(label)) return '<i class="bi bi-browser-edge"></i>';
if (/Opera/i.test(label)) return '<i class="bi bi-globe2"></i>';
return '<i class="bi bi-globe"></i>';
}
function _iconForOs(label) {
if (/Windows/i.test(label)) return '<i class="bi bi-windows"></i>';
if (/macOS/i.test(label)) return '<i class="bi bi-apple"></i>';
if (/iOS|iPadOS/i.test(label)) return '<i class="bi bi-apple"></i>';
if (/Android/i.test(label)) return '<i class="bi bi-android2"></i>';
if (/Linux|ChromeOS/i.test(label)) return '<i class="bi bi-ubuntu"></i>';
return '<i class="bi bi-question-circle"></i>';
}
function openViewerDetail(who, name, avatar) {
const baseUrl = document.getElementById('vdb-insights')?.dataset.insightsBase || '';
_modalLoading('👤', name, 'Loading viewer details…');
document.getElementById('insModalIcon').innerHTML =
`<img src="${avatar}" style="width:32px;height:32px;border-radius:50%;object-fit:cover;">`;
fetch(`${baseUrl}/viewer/${encodeURIComponent(who)}`, {
headers:{'Accept':'application/json','X-Requested-With':'XMLHttpRequest'}
})
.then(r=>{ if(!r.ok) throw r; return r.json(); })
.then(d => {
const id = d.identity;
document.getElementById('insModalIcon').innerHTML =
`<img src="${id.avatar || avatar}" style="width:32px;height:32px;border-radius:50%;object-fit:cover;">`;
document.getElementById('insModalTitle').textContent = id.name;
document.getElementById('insModalSubtitle').textContent =
`${_fmt(d.total)} view${d.total!==1?'s':''} · first ${_ago(d.first_at)} · last ${_ago(d.last_at)}`;
const countriesHtml = (d.countries||[]).map(c => `
<div class="ins-country-row" style="cursor:default;">
<div class="ins-country-flag">${_cflag(c.code)}</div>
<div class="ins-country-name">${c.name||c.code}</div>
<div class="ins-country-cnt" style="margin-left:auto;">${_fmt(c.count)}×</div>
</div>`).join('') || '<p style="font-size:13px;color:var(--text-secondary);margin:0;">No location data.</p>';
const profileBtn = (!id.is_guest && id.channel)
? `<div style="text-align:center;margin:-6px 0 14px;">
<a href="/channel/${id.channel}" class="action-btn" style="display:inline-flex;">
<i class="bi bi-person-circle"></i> <span>View profile</span>
</a>
</div>` : '';
const recentHtml = (d.recent||[]).slice(0, 20).map((r,i) => `
<div class="ins-recent-row">
<div style="font-size:11px;font-weight:700;color:var(--text-secondary);min-width:18px;text-align:center;">${i+1}</div>
<span class="ins-recent-flag">${_cflag(r.country)}</span>
<div class="ins-recent-name" style="font-weight:500;">${_fmtDt(r.at)}</div>
<span class="ins-recent-time">${_ago(r.at)}</span>
</div>`).join('');
document.getElementById('insModalBody').innerHTML = `
<div class="ins-modal-hero">
<div class="ins-modal-hero-num">${_fmt(d.total)}</div>
<div class="ins-modal-hero-label">total view${d.total!==1?'s':''} by this ${id.is_guest?'guest':'viewer'}</div>
</div>
${profileBtn}
<div class="ins-modal-stat">
<span class="ins-modal-stat-lbl"><i class="bi bi-calendar-check"></i> First seen</span>
<span class="ins-modal-stat-val">${_fmtDt(d.first_at)} <span style="color:var(--text-secondary);font-weight:400;">(${_ago(d.first_at)})</span></span>
</div>
<div class="ins-modal-stat" style="border-bottom:none;margin-bottom:14px;">
<span class="ins-modal-stat-lbl"><i class="bi bi-clock-history"></i> Last seen</span>
<span class="ins-modal-stat-val">${_fmtDt(d.last_at)} <span style="color:var(--text-secondary);font-weight:400;">(${_ago(d.last_at)})</span></span>
</div>
<div class="ins-modal-section"><i class="bi bi-globe2"></i> Locations</div>
${countriesHtml}
<div class="ins-modal-section"><i class="bi bi-phone"></i> Devices</div>
${_uaBucketsHtml(d.devices, _iconForDevice)}
<div class="ins-modal-section"><i class="bi bi-browser-chrome"></i> Browsers</div>
${_uaBucketsHtml(d.browsers, _iconForBrowser)}
<div class="ins-modal-section"><i class="bi bi-cpu"></i> Operating Systems</div>
${_uaBucketsHtml(d.os, _iconForOs)}
${recentHtml ? `
<div class="ins-modal-section"><i class="bi bi-list-ul"></i> Recent views ${d.recent.length>20?'(showing 20 of '+d.total+')':''}</div>
<div class="ins-recent-scroll" style="max-height:280px;">${recentHtml}</div>` : ''}
`;
})
.catch(() => {
document.getElementById('insModalBody').innerHTML =
'<p style="color:var(--text-secondary);font-size:13px;padding:8px 0;">Could not load viewer details.</p>';
});
}
// ══════════════════════════════════════════════════════
// DRILL-DOWN: SINGLE SHARE LINK
// ══════════════════════════════════════════════════════
function openShareDetail(token, sharerName) {
const baseUrl = document.getElementById('vdb-insights')?.dataset.insightsBase || '';
_modalLoading('🔗', sharerName + "'s share link", 'Loading link reach…');
fetch(`${baseUrl}/share/${encodeURIComponent(token)}`, {
headers:{'Accept':'application/json','X-Requested-With':'XMLHttpRequest'}
})
.then(r=>{ if(!r.ok) throw r; return r.json(); })
.then(d => {
const s = d.sharer;
document.getElementById('insModalIcon').innerHTML = s.avatar
? `<img src="${s.avatar}" style="width:32px;height:32px;border-radius:50%;object-fit:cover;">`
: '🔗';
document.getElementById('insModalTitle').textContent = `${s.name}'s share link`;
document.getElementById('insModalSubtitle').textContent = `Created ${_fmtDt(d.created_at)} · ${_ago(d.created_at)}`;
const countriesHtml = (d.countries||[]).length
? d.countries.map(c => `
<div class="ins-country-row" style="cursor:default;">
<div class="ins-country-flag">${_cflag(c.code)}</div>
<div class="ins-country-name">${c.name||c.code}</div>
<div class="ins-country-cnt" style="margin-left:auto;">${_fmt(c.count)}×</div>
</div>`).join('')
: '<p style="font-size:13px;color:var(--text-secondary);margin:0;">No location data yet.</p>';
const recentHtml = (d.recent||[]).slice(0, 50).map((r,i) => `
<div class="ins-recent-row" style="flex-wrap:wrap;">
<div style="font-size:11px;font-weight:700;color:var(--text-secondary);min-width:18px;text-align:center;">${i+1}</div>
<span class="ins-recent-flag">${_cflag(r.country)}</span>
<div class="ins-recent-name" style="font-weight:500;">${_fmtDt(r.at)}</div>
<span class="ins-recent-time">${_ago(r.at)}</span>
<div style="flex-basis:100%;font-size:11px;color:var(--text-secondary);padding-left:34px;">${_uaInline(r)}</div>
</div>`).join('');
const profileBtn = (!s.is_guest && s.channel)
? `<div style="text-align:center;margin:-6px 0 14px;">
<a href="/channel/${s.channel}" class="action-btn" style="display:inline-flex;">
<i class="bi bi-person-circle"></i> <span>View ${s.name}'s profile</span>
</a>
</div>` : '';
// Devices / browsers / OS sections only render if we have at least one bucket
const devSection = (d.devices||[]).length ? `
<div class="ins-modal-section"><i class="bi bi-phone"></i> Devices</div>
${_uaBucketsHtml(d.devices, _iconForDevice)}` : '';
const brSection = (d.browsers||[]).length ? `
<div class="ins-modal-section"><i class="bi bi-browser-chrome"></i> Browsers</div>
${_uaBucketsHtml(d.browsers, _iconForBrowser)}` : '';
const osSection = (d.os||[]).length ? `
<div class="ins-modal-section"><i class="bi bi-cpu"></i> Operating Systems</div>
${_uaBucketsHtml(d.os, _iconForOs)}` : '';
document.getElementById('insModalBody').innerHTML = `
<div class="ins-modal-hero">
<div class="ins-modal-hero-num">${_fmt(d.reach)}</div>
<div class="ins-modal-hero-label">unique device${d.reach===1?'':'s'} came from this link</div>
</div>
${profileBtn}
<div class="ins-modal-stat">
<span class="ins-modal-stat-lbl"><i class="bi bi-person"></i> Shared by</span>
<span class="ins-modal-stat-val">${s.name}${s.is_guest?' (guest)':''}</span>
</div>
<div class="ins-modal-stat" style="border-bottom:none;margin-bottom:14px;">
<span class="ins-modal-stat-lbl"><i class="bi bi-link-45deg"></i> Link created</span>
<span class="ins-modal-stat-val">${_fmtDt(d.created_at)} <span style="color:var(--text-secondary);font-weight:400;">(${_ago(d.created_at)})</span></span>
</div>
<div class="ins-modal-section"><i class="bi bi-globe2"></i> Where viewers came from</div>
${countriesHtml}
${devSection}
${brSection}
${osSection}
${recentHtml ? `
<div class="ins-modal-section"><i class="bi bi-clock-history"></i> Recent visits ${d.recent.length>=50?'(showing 50 of '+d.reach+')':''}</div>
<div class="ins-recent-scroll" style="max-height:320px;">${recentHtml}</div>
` : '<p style="font-size:13px;color:var(--text-secondary);margin:14px 0 0;">No one has opened this link yet.</p>'}
<p style="font-size:11px;color:var(--text-secondary);margin:14px 0 0;line-height:1.5;">
The same device opening this link again is counted only once — VPN IP changes don't inflate the count.
</p>
`;
})
.catch(() => {
document.getElementById('insModalBody').innerHTML =
'<p style="color:var(--text-secondary);font-size:13px;padding:8px 0;">Could not load share link details.</p>';
});
}
</script>
@endif