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
This commit is contained in:
1 parent
6aae6f86b6
commit
73527f3781
61 files changed
+4014
-636
No files matched your search
@@ -10,9 +10,10 @@
|
||||
@if($isVideoOwner)
|
||||
<style>
|
||||
/* ── Stat cards ──────────────────────────────────────── */
|
||||
.ins-grid { display:grid; grid-template-columns:repeat(5,1fr); gap:10px; margin-bottom:18px; }
|
||||
@media(max-width:860px){ .ins-grid { grid-template-columns:repeat(3,1fr); } }
|
||||
@media(max-width:560px){ .ins-grid { grid-template-columns:repeat(2,1fr); } }
|
||||
.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); }
|
||||
@@ -30,6 +31,18 @@
|
||||
.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; }
|
||||
@@ -60,8 +73,23 @@
|
||||
.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 0; border-bottom:1px solid rgba(255,255,255,.04); font-size:12px; }
|
||||
.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; }
|
||||
@@ -193,6 +221,19 @@ 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'; };
|
||||
@@ -232,6 +273,44 @@ function _personListHtml(users, emptyMsg) {
|
||||
</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);
|
||||
@@ -308,58 +387,61 @@ function renderInsights(d) {
|
||||
<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()">
|
||||
<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">Total Views</div>${weekBadge}
|
||||
<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_viewers()">
|
||||
<div class="ins-card-icon">👤</div><div class="ins-card-val">${_fmt(d.unique_viewers)}</div>
|
||||
<div class="ins-card-label">Unique Viewers</div>
|
||||
<span class="ins-card-sub neu">${_fmt(d.views_today)} today</span>
|
||||
<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_likes()">
|
||||
<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_shares()">
|
||||
<div class="ins-card-icon">🔗</div><div class="ins-card-val">${_fmt(d.shares)}</div>
|
||||
<div class="ins-card-label">Share Reach</div>
|
||||
<span class="ins-card-sub neu">${_fmt(d.share_links)} link${d.share_links===1?'':'s'} created</span>
|
||||
<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 (each bar clickable) ──────────────────
|
||||
const maxV = Math.max(...d.daily.map(x=>x.count),1);
|
||||
const todayL = new Date().toLocaleDateString('en-US',{month:'short',day:'numeric'});
|
||||
const bars = d.daily.map(day => {
|
||||
const h = Math.round((day.count/maxV)*72);
|
||||
const isT = day.label===todayL;
|
||||
const tip = `${day.label}: ${day.count} views`;
|
||||
return `<div class="ins-bar-col">
|
||||
<div class="ins-bar clickable${isT?' ins-bar-today':''}" style="height:${Math.max(h,2)}px;"
|
||||
title="${tip}" onclick="openDayDetail('${day.date}','${day.label}',${day.count})"></div>
|
||||
<div class="ins-bar-label">${day.short}</div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
// ── Bar chart (segmented + each bar clickable) ──────
|
||||
const bars = _segmentedBars(d.daily, 72, {
|
||||
onClick: `openDayDetail('__DATE__','__LABEL__',__COUNT__)`
|
||||
});
|
||||
|
||||
const peakHtml = 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>` : '';
|
||||
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>` : '';
|
||||
|
||||
const chartSection = `
|
||||
// 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></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>
|
||||
<div style="font-size:11px;color:var(--text-secondary);text-align:right;">${d.views_this_week} views this week</div>
|
||||
${peakHtml}
|
||||
</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 = '<p style="font-size:13px;color:var(--text-secondary);margin:0;">No location data yet.</p>';
|
||||
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">
|
||||
@@ -379,7 +461,7 @@ function renderInsights(d) {
|
||||
<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">Last seen ${_ago(u.last_at)}</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>
|
||||
@@ -387,31 +469,47 @@ function renderInsights(d) {
|
||||
|
||||
const recentViewerRows = (d.recent_viewers || []).map(r => {
|
||||
const avatarSrc = r.user_avatar || `https://i.pravatar.cc/150?u=guest-${r.country||'unknown'}`;
|
||||
const clickAttr = r.user_id ? `onclick="window.location.href='/channel/${r.user_channel||r.user_id}'" style="cursor:pointer;" title="View ${r.user_name}'s profile"` : '';
|
||||
return `<div class="ins-recent-row" ${clickAttr}>
|
||||
<img src="${avatarSrc}" class="ins-recent-avatar" alt="${r.user_name}">
|
||||
<div class="ins-recent-name">${r.user_name}</div>
|
||||
<span class="ins-recent-flag">${_cflag(r.country)}</span>
|
||||
<span class="ins-recent-time">${_ago(r.at)}</span>
|
||||
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('');
|
||||
|
||||
const 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="ins-two-col">
|
||||
// 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 || '<p style="font-size:13px;color:var(--text-secondary);margin:0;">No registered viewers yet.</p>'}
|
||||
</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</div>
|
||||
${recentViewerRows || '<p style="font-size:13px;color:var(--text-secondary);margin:0;">No views yet.</p>'}
|
||||
<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>
|
||||
</div>`;
|
||||
<div class="${wrap}">${topCol}${recentCol}</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// ── Who Liked section ───────────────────────────────
|
||||
let likersHtml = '';
|
||||
@@ -429,123 +527,114 @@ function renderInsights(d) {
|
||||
<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>`;
|
||||
} else if (d.likes === 0) {
|
||||
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" style="color:var(--text-secondary);"></i> Likes</div>
|
||||
<p style="font-size:13px;color:var(--text-secondary);margin:0;">No likes yet.</p>
|
||||
</div>`;
|
||||
}
|
||||
// (When d.likes === 0 the section is omitted entirely.)
|
||||
|
||||
// ── Downloads section ───────────────────────────────
|
||||
let dlHtml = '';
|
||||
if (d.downloads===0) {
|
||||
dlHtml = `<div style="margin-top:20px;"><div class="ins-section-title"><i class="bi bi-download"></i> Downloads</div>
|
||||
<p style="font-size:13px;color:var(--text-secondary);margin:0;">No downloads yet.</p></div>`;
|
||||
} else {
|
||||
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
|
||||
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">Last: ${_ago(u.last_at)}</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('')
|
||||
: '<p style="font-size:13px;color:var(--text-secondary);margin:0;">Guests only so far.</p>';
|
||||
: '';
|
||||
|
||||
const recentRows = (d.dl_recent||[]).map(r => {
|
||||
const avatarSrc = r.user_avatar || `https://i.pravatar.cc/150?u=guest-${r.country||'unknown'}`;
|
||||
const av = `<img src="${avatarSrc}" class="ins-recent-avatar" alt="${r.user_name}">`;
|
||||
return `<div class="ins-recent-row">${av}
|
||||
<div class="ins-recent-name">${r.user_name}</div>
|
||||
<span class="ins-recent-flag">${_cflag(r.country)}</span>
|
||||
<span class="ins-recent-type ${r.type}">${r.type.toUpperCase()}</span>
|
||||
<span class="ins-recent-time">${_ago(r.at)}</span>
|
||||
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="ins-two-col">
|
||||
<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>
|
||||
<div>
|
||||
<div style="font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--text-secondary);margin-bottom:8px;">Recent Activity</div>
|
||||
${recentRows}
|
||||
</div>
|
||||
</div>
|
||||
<div class="${dlWrap}">${topCol}${recentCol}</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// ── Gender breakdown ────────────────────────────────
|
||||
let genderHtml;
|
||||
if (d.genders && d.genders.length > 0) {
|
||||
const gColors = { male: '#4a9eff', female: '#ff6eb0' };
|
||||
const gSymbols = { male: '♂', female: '♀' };
|
||||
genderHtml = d.genders.map(g => {
|
||||
const color = gColors[g.gender] || '#aaa';
|
||||
const sym = gSymbols[g.gender] || '⚧';
|
||||
return `<div class="ins-demo-row">
|
||||
<span class="ins-demo-sym" style="color:${color}">${sym}</span>
|
||||
<span class="ins-demo-label">${g.gender.charAt(0).toUpperCase()+g.gender.slice(1)}</span>
|
||||
<div class="ins-demo-bar-wrap"><div class="ins-demo-bar" style="width:${g.pct}%;background:${color}"></div></div>
|
||||
<span class="ins-demo-pct">${g.pct}%</span>
|
||||
<span class="ins-demo-cnt">${_fmt(g.count)}</span>
|
||||
</div>`;
|
||||
}).join('');
|
||||
} else {
|
||||
genderHtml = `<p style="font-size:13px;color:var(--text-secondary);margin:0;">No gender data yet — viewers need a profile to appear here.</p>`;
|
||||
}
|
||||
|
||||
// ── Age group breakdown ──────────────────────────────
|
||||
let ageHtml;
|
||||
// ── 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);
|
||||
return `<div class="ins-demo-row">
|
||||
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 class="ins-demo-bar" style="width:${barW}%;background:var(--brand-red,#e61e1e)"></div></div>
|
||||
<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('');
|
||||
} else {
|
||||
ageHtml = `<p style="font-size:13px;color:var(--text-secondary);margin:0;">No age data yet — viewers need a date of birth on their profile to appear here.</p>`;
|
||||
}
|
||||
|
||||
const demographicsHtml = `
|
||||
<div style="margin-top:18px;border-top:1px solid var(--border-color);padding-top:18px;">
|
||||
<div class="ins-body">
|
||||
<div>
|
||||
<div class="ins-section-title"><i class="bi bi-gender-ambiguous"></i> Viewers by Gender</div>
|
||||
${genderHtml}
|
||||
</div>
|
||||
<div>
|
||||
<div class="ins-section-title"><i class="bi bi-people-fill"></i> Viewers by Age Group</div>
|
||||
${ageHtml}
|
||||
</div>
|
||||
</div>
|
||||
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) {
|
||||
@@ -553,9 +642,8 @@ function renderInsights(d) {
|
||||
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 clickAttr = s.sharer_channel
|
||||
? `onclick="window.location.href='/channel/${s.sharer_channel}'" style="cursor:pointer;" title="View ${s.sharer}'s profile"`
|
||||
: `style="cursor:default;"`;
|
||||
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>`
|
||||
: '';
|
||||
@@ -568,6 +656,7 @@ function renderInsights(d) {
|
||||
</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('');
|
||||
|
||||
@@ -579,21 +668,40 @@ function renderInsights(d) {
|
||||
<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>`;
|
||||
} else {
|
||||
shareHtml = `
|
||||
<div style="margin-top:20px;border-top:1px solid var(--border-color);padding-top:18px;">
|
||||
<div class="ins-section-title"><i class="bi bi-share"></i> Share Links</div>
|
||||
<p style="font-size:13px;color:var(--text-secondary);margin:0;">No share links created yet. Tap the Share button to generate a unique tracked link.</p>
|
||||
</div>`;
|
||||
}
|
||||
// (When d.share_links === 0 the section is omitted entirely.)
|
||||
|
||||
document.getElementById('insightsContent').innerHTML =
|
||||
cards +
|
||||
`<div class="ins-body">${chartSection}
|
||||
// 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></div>` + viewersHtml + likersHtml + dlHtml + shareHtml + demographicsHtml;
|
||||
</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;
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════
|
||||
@@ -611,16 +719,14 @@ function openInsModal_views() {
|
||||
<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" style="border-bottom:none;margin-bottom:18px;">
|
||||
<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-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;">${window._insData.daily.map(day=>{
|
||||
const h=Math.round((day.count/Math.max(...window._insData.daily.map(x=>x.count),1))*68);
|
||||
const isT=day.label===new Date().toLocaleDateString('en-US',{month:'short',day:'numeric'});
|
||||
return `<div class="ins-bar-col"><div class="ins-bar clickable${isT?' ins-bar-today':''}" style="height:${Math.max(h,2)}px;" title="${day.label}: ${day.count}" onclick="closeInsModal();setTimeout(()=>openDayDetail('${day.date}','${day.label}',${day.count}),180)"></div><div class="ins-bar-label">${day.short}</div></div>`;
|
||||
}).join('')}</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>`:''}`);
|
||||
}
|
||||
|
||||
@@ -689,9 +795,8 @@ function openInsModal_shares() {
|
||||
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 clickAttr = s.sharer_channel
|
||||
? `onclick="window.location.href='/channel/${s.sharer_channel}'" style="cursor:pointer;" title="View ${s.sharer}'s profile"`
|
||||
: `style="cursor:default;"`;
|
||||
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>`
|
||||
: '';
|
||||
@@ -704,6 +809,7 @@ function openInsModal_shares() {
|
||||
</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>';
|
||||
@@ -750,6 +856,112 @@ function openInsModal_likes() {
|
||||
${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
|
||||
// ══════════════════════════════════════════════════════
|
||||
@@ -895,5 +1107,216 @@ function openDownloaderHistory(userId, userName, userAvatar) {
|
||||
'<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
|
||||
Reference in new issue
Block a user