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:
@@ -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>
|
||||
Reference in New Issue
Block a user