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:
jay
2026-06-05 12:58:49 +00:00
parent cfde4e22db
commit 427210ac3e
16 changed files with 460 additions and 38 deletions
@@ -0,0 +1,103 @@
<script>
import { postJSON } from '$lib/api.js';
import { visitorId } from '$lib/analytics.js';
let { onclose } = $props();
let category = $state('idea');
let message = $state('');
let email = $state('');
let hp = $state(''); // honeypot — must stay empty
let status = $state('idle'); // idle | sending | sent | error
let error = $state('');
const CATEGORIES = [
['idea', 'Idea'],
['concern', 'Concern'],
['bug', 'Bug'],
['praise', 'Praise'],
];
async function submit(e) {
e.preventDefault();
if (!message.trim() || status === 'sending') return;
status = 'sending';
error = '';
try {
await postJSON('/api/feedback', { category, message, email, hp, visitor: visitorId() });
status = 'sent';
} catch (err) {
status = 'error';
error = err?.message || 'Something went wrong — try again.';
}
}
function onkey(e) {
if (e.key === 'Escape') onclose?.();
}
</script>
<svelte:window onkeydown={onkey} />
<div class="overlay" onclick={onclose} role="presentation">
<div class="sheet rise" role="dialog" aria-modal="true" aria-label="Send feedback" onclick={(e) => e.stopPropagation()}>
<button class="x" onclick={onclose} aria-label="Close">×</button>
{#if status === 'sent'}
<h2>Thank you 💛</h2>
<p class="sub">Your note is on its way to us. We read every one.</p>
<button class="primary" onclick={onclose}>Done</button>
{:else}
<h2>Share feedback</h2>
<p class="sub">An idea, a concern, a bug, or just a hello — we'd love to hear it.</p>
<form onsubmit={submit}>
<div class="cats">
{#each CATEGORIES as [val, label] (val)}
<button type="button" class="cat" class:on={category === val} onclick={() => (category = val)}>{label}</button>
{/each}
</div>
<textarea bind:value={message} rows="4" placeholder="What's on your mind?" required></textarea>
<input type="email" bind:value={email} placeholder="Email (optional, only if you'd like a reply)" autocomplete="email" />
<!-- honeypot: hidden from people, tempting to bots -->
<input class="hp" tabindex="-1" autocomplete="off" bind:value={hp} aria-hidden="true" />
<button class="primary" type="submit" disabled={status === 'sending'}>
{status === 'sending' ? 'Sending…' : 'Send feedback'}
</button>
</form>
{#if status === 'error'}<p class="err">{error}</p>{/if}
{/if}
</div>
</div>
<style>
.overlay {
position: fixed; inset: 0; background: rgba(10, 22, 38, 0.32);
display: flex; align-items: center; justify-content: center; padding: 20px; z-index: 60;
}
.sheet {
background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius);
box-shadow: var(--shadow); width: 100%; max-width: 420px; padding: 26px 24px 22px; position: relative;
}
.x { position: absolute; top: 12px; right: 14px; background: none; border: none; font-size: 1.5rem; line-height: 1; color: var(--muted); cursor: pointer; }
h2 { font-size: 1.4rem; margin: 0 0 6px; }
.sub { margin: 0 0 16px; color: var(--muted); font-size: 0.92rem; }
form { display: flex; flex-direction: column; gap: 10px; }
.cats { display: flex; flex-wrap: wrap; gap: 6px; }
.cat {
border: 1px solid var(--line); background: var(--bg); color: var(--ink);
border-radius: 999px; padding: 5px 13px; font-size: 0.85rem; cursor: pointer;
}
.cat.on { background: var(--accent); border-color: var(--accent); color: #fff; }
textarea, input {
font: inherit; padding: 10px 12px; border: 1px solid var(--line); border-radius: 12px;
background: var(--bg); color: var(--ink); resize: vertical;
}
textarea:focus, input:focus { outline: none; border-color: var(--accent); }
.hp { position: absolute; left: -9999px; width: 1px; height: 1px; opacity: 0; }
.primary {
font: inherit; font-weight: 600; background: var(--accent); color: #fff; border: none;
border-radius: 999px; padding: 11px 16px; cursor: pointer;
}
.primary:hover { background: var(--accent-deep); }
.primary:disabled { opacity: 0.6; cursor: default; }
.err { margin: 10px 0 0; color: #9a3b3b; font-size: 0.86rem; }
</style>