User feedback + expanded privacy-respecting admin stats
Feedback: - feedback table; POST /api/feedback (anonymous-ok, optional category/email, honeypot + per-day flood cap) stores + emails the admin; GET /api/admin/feedback. - Shared feedback store + FeedbackModal; a speech-bubble opens it from the desktop header, the mobile top bar (logo moves left), the footer, and /account. Feedback section in /admin. Stats (additive, same privacy model — no IP/UA/referrer/raw terms): - Event vocab: summary_viewed (fired on /a load), full_story (card → source), not_today/less_like_this/hide_topic, replace_used/replace_none, paywall_replace, paywalled_source_open. Card title/image opens /a (no double-count); history records via keepalive so it survives the nav. - Dashboard: Accounts card (counts only), reading funnel (summary→source rate), emotional-mix & friction, paywall, returning-visitor buckets. (Health metrics deferred to a future monitoring dashboard.) 131 tests pass. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -5,6 +5,7 @@
|
||||
import { auth, refresh } from '$lib/auth.svelte.js';
|
||||
|
||||
let stats = $state(null);
|
||||
let feedback = $state([]);
|
||||
let error = $state('');
|
||||
|
||||
onMount(async () => {
|
||||
@@ -15,11 +16,17 @@
|
||||
}
|
||||
try {
|
||||
stats = await getJSON('/api/admin/stats');
|
||||
feedback = await getJSON('/api/admin/feedback');
|
||||
} catch {
|
||||
error = "Couldn't load stats.";
|
||||
}
|
||||
});
|
||||
|
||||
function fdate(s) {
|
||||
try { return new Date(s + 'Z').toLocaleDateString(undefined, { month: 'short', day: 'numeric' }); }
|
||||
catch { return s; }
|
||||
}
|
||||
|
||||
const SHARE_LABEL = {
|
||||
share_ub: 'Copied UB link',
|
||||
native_share: 'Native share',
|
||||
@@ -53,10 +60,51 @@
|
||||
<div class="stat"><span class="n">{stats.visitors.today}</span><span class="l">Visitors today</span></div>
|
||||
<div class="stat"><span class="n">{stats.visitors.d7}</span><span class="l">Last 7 days</span></div>
|
||||
<div class="stat"><span class="n">{stats.visitors.d30}</span><span class="l">Last 30 days</span></div>
|
||||
<div class="stat"><span class="n">{stats.returning}</span><span class="l">Returning</span></div>
|
||||
<div class="stat"><span class="n">{stats.once}</span><span class="l">One-and-done</span></div>
|
||||
</div>
|
||||
|
||||
<section>
|
||||
<h2>Accounts</h2>
|
||||
<div class="cards">
|
||||
<div class="stat"><span class="n">{stats.accounts.total}</span><span class="l">Total</span></div>
|
||||
<div class="stat"><span class="n">{stats.accounts.new_today}</span><span class="l">New today</span></div>
|
||||
<div class="stat"><span class="n">{stats.accounts.new_7d}</span><span class="l">New this week</span></div>
|
||||
<div class="stat"><span class="n">{stats.accounts.active_7d}</span><span class="l">Active this week</span></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Returning visitors (30d)</h2>
|
||||
<div class="cards">
|
||||
<div class="stat"><span class="n">{stats.retention.new}</span><span class="l">New (1 day)</span></div>
|
||||
<div class="stat"><span class="n">{stats.retention['2-3']}</span><span class="l">2–3 days</span></div>
|
||||
<div class="stat"><span class="n">{stats.retention['4-7']}</span><span class="l">4–7 days</span></div>
|
||||
<div class="stat"><span class="n">{stats.retention['8+']}</span><span class="l">8+ days</span></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Reading funnel</h2>
|
||||
<div class="cards">
|
||||
<div class="stat"><span class="n">{stats.funnel.summary_viewed}</span><span class="l">Summaries read</span></div>
|
||||
<div class="stat"><span class="n">{stats.funnel.source_click}</span><span class="l">→ Source (from summary)</span></div>
|
||||
<div class="stat"><span class="n">{stats.funnel.source_rate}%</span><span class="l">Summary → source rate</span></div>
|
||||
<div class="stat"><span class="n">{stats.funnel.full_story}</span><span class="l">Straight to source</span></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Emotional mix & friction</h2>
|
||||
<div class="cards">
|
||||
<div class="stat"><span class="n">{stats.emotional_mix.not_today}</span><span class="l">Not today</span></div>
|
||||
<div class="stat"><span class="n">{stats.emotional_mix.less_like_this}</span><span class="l">Less like this</span></div>
|
||||
<div class="stat"><span class="n">{stats.emotional_mix.hide_topic}</span><span class="l">Hide topic</span></div>
|
||||
<div class="stat"><span class="n">{stats.replace.used}</span><span class="l">Replaces</span></div>
|
||||
<div class="stat"><span class="n">{stats.replace.none}</span><span class="l">No replacement</span></div>
|
||||
<div class="stat"><span class="n">{stats.paywall.paywall_replace}</span><span class="l">Paywall replaces</span></div>
|
||||
<div class="stat"><span class="n">{stats.paywall.paywalled_source_open}</span><span class="l">Paywalled opens</span></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Most-opened articles</h2>
|
||||
{#if stats.top_articles.length}
|
||||
@@ -128,6 +176,24 @@
|
||||
<p class="legend"><span class="sw visits"></span> visits <span class="sw opens"></span> opens</p>
|
||||
{:else}<p class="muted">No data yet.</p>{/if}
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Feedback {#if feedback.length}<span class="count">({feedback.length})</span>{/if}</h2>
|
||||
{#if feedback.length}
|
||||
<ul class="fb">
|
||||
{#each feedback as f (f.id)}
|
||||
<li>
|
||||
<div class="fhead">
|
||||
<span class="cat">{f.category}</span>
|
||||
<span class="when">{fdate(f.created_at)}</span>
|
||||
{#if f.contact_email}<a class="reply" href={'mailto:' + f.contact_email}>{f.contact_email}</a>{/if}
|
||||
</div>
|
||||
<p class="msg">{f.message}</p>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{:else}<p class="muted">No feedback yet.</p>{/if}
|
||||
</section>
|
||||
{/if}
|
||||
</main>
|
||||
|
||||
@@ -176,4 +242,13 @@
|
||||
.legend .sw { display: inline-block; width: 10px; height: 10px; border-radius: 2px; vertical-align: middle; }
|
||||
.legend .sw.visits { background: var(--accent-soft); }
|
||||
.legend .sw.opens { background: var(--accent); }
|
||||
|
||||
.count { color: var(--muted); font-weight: 400; font-size: 0.9rem; }
|
||||
ul.fb { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 10px; }
|
||||
ul.fb li { background: var(--surface); border: 1px solid var(--line); border-radius: 12px; padding: 12px 14px; }
|
||||
.fhead { display: flex; align-items: center; gap: 10px; margin-bottom: 5px; font-size: 0.78rem; }
|
||||
.fhead .cat { background: var(--accent-soft); color: var(--accent-deep); border-radius: 999px; padding: 2px 9px; text-transform: capitalize; }
|
||||
.fhead .when { color: var(--muted); }
|
||||
.fhead .reply { color: var(--accent-deep); margin-left: auto; }
|
||||
.msg { margin: 0; color: var(--ink); font-size: 0.92rem; white-space: pre-wrap; }
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user