- 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
1323 lines
89 KiB
PHP
1323 lines
89 KiB
PHP
{{--
|
||
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 & 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
|