In-site feedback reply (plain-text v1)

Reply to a reader from the admin inbox instead of a mailto. Per Codex: keep v1
plain text (no rich editor — defers the user's bold/bullets ask as a fast-follow).

* DB: feedback_replies table (feedback_id, user_id, message, sent_to, sent_at),
  created on the live DB.
* email_send.send_feedback_reply: plain-text "Re: Your Upbeat Bytes feedback"
  with a quoted context block, no analytics/account details.
* API: POST /api/admin/feedback/{id}/reply — admin-gated, requires the feedback
  exists (404) and has a contact_email (400), trims+caps the message; sends via
  SMTP and only records the reply on success (502 on send failure so the UI keeps
  the draft); marks the item read. Feedback list now includes each item's replies.
* Frontend: inline composer (Send/Cancel, sending state, error keeps draft) +
  reply thread under the message; Reply only shows when there's an address,
  else "No reply address".

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
jay
2026-06-08 14:23:56 -04:00
parent 8cce3a2165
commit 84fd61bf3f
5 changed files with 185 additions and 3 deletions
+77 -2
View File
@@ -130,6 +130,29 @@
}
}
// In-site reply (plain text, sent via SMTP; only stored on successful send).
let replyingId = $state(null);
let replyText = $state('');
let replyBusy = $state(false);
let replyErr = $state('');
function openReply(f) { replyingId = f.id; replyText = ''; replyErr = ''; }
function cancelReply() { replyingId = null; replyText = ''; replyErr = ''; }
async function sendReply(f) {
const msg = replyText.trim();
if (!msg || replyBusy) return;
replyBusy = true; replyErr = '';
try {
const res = await postJSON(`/api/admin/feedback/${f.id}/reply`, { message: msg });
f.replies = [...(f.replies || []), res.reply];
f.read_at = f.read_at || res.reply.sent_at; // server marked it read
replyingId = null; replyText = '';
} catch (e) {
replyErr = e?.message || 'Could not send — the draft is kept.';
} finally {
replyBusy = false;
}
}
const SHARE_LABEL = {
share_ub: 'Copied UB link',
native_share: 'Native share',
@@ -396,10 +419,40 @@
{#if !f.read_at}<span class="dot" title="Unread"></span>{/if}
<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}>Reply ↩</a>{:else}<span class="anon">anonymous</span>{/if}
{#if f.contact_email}<span class="addr">{f.contact_email}</span>{:else}<span class="anon">anonymous</span>{/if}
</div>
<p class="msg">{f.message}</p>
{#if f.replies?.length}
<div class="thread">
{#each f.replies as r (r.id)}
<div class="rep">
<span class="rl">↪ Replied · {fwhen(r.sent_at)}</span>
<p class="repmsg">{r.message}</p>
</div>
{/each}
</div>
{/if}
{#if replyingId === f.id}
<div class="composer">
<textarea bind:value={replyText} rows="4" placeholder="Write a reply…"></textarea>
{#if replyErr}<p class="cerr">{replyErr}</p>{/if}
<div class="cbtns">
<button class="csend" onclick={() => sendReply(f)} disabled={replyBusy || !replyText.trim()}>
{replyBusy ? 'Sending…' : 'Send reply'}
</button>
<button class="act" onclick={cancelReply}>Cancel</button>
</div>
</div>
{/if}
<div class="factions">
{#if f.contact_email && replyingId !== f.id}
<button class="act" onclick={() => openReply(f)}>Reply</button>
{:else if !f.contact_email}
<span class="noreply">No reply address</span>
{/if}
<button class="act" onclick={() => markRead(f, !f.read_at)}>{f.read_at ? 'Mark unread' : 'Mark read'}</button>
<button class="act del" onclick={() => removeFeedback(f)}>Delete</button>
</div>
@@ -536,9 +589,31 @@
.fhead .dot { width: 7px; height: 7px; border-radius: 50%; background: var(--accent); flex-shrink: 0; }
.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; }
.fhead .addr { color: var(--accent-deep); margin-left: auto; }
.fhead .anon { color: var(--muted); margin-left: auto; font-style: italic; }
.msg { margin: 0; color: var(--ink); font-size: 0.92rem; white-space: pre-wrap; }
/* Reply thread + composer */
.thread { margin: 10px 0 0; display: flex; flex-direction: column; gap: 8px; }
.rep { border-left: 2px solid var(--accent-soft); padding: 2px 0 2px 12px; }
.rep .rl { font-size: 0.72rem; color: var(--muted); }
.rep .repmsg { margin: 2px 0 0; font-size: 0.9rem; color: var(--ink); white-space: pre-wrap; }
.composer { margin: 10px 0 0; }
.composer textarea {
width: 100%; box-sizing: border-box; font: inherit; font-size: 0.9rem;
padding: 10px 12px; border: 1px solid var(--line); border-radius: 10px;
background: var(--bg); color: var(--ink); resize: vertical;
}
.composer textarea:focus { outline: none; border-color: var(--accent); }
.cerr { margin: 6px 0 0; color: #9a3b3b; font-size: 0.82rem; }
.cbtns { display: flex; align-items: center; gap: 12px; margin-top: 8px; }
.csend {
font: inherit; font-size: 0.82rem; font-weight: 600; background: var(--accent); color: #fff;
border: none; border-radius: 999px; padding: 7px 18px; cursor: pointer;
}
.csend:hover { background: var(--accent-deep); }
.csend:disabled { opacity: 0.55; cursor: default; }
.noreply { color: var(--muted); font-size: 0.76rem; font-style: italic; }
.factions { display: flex; gap: 14px; margin-top: 9px; }
.factions .act {
background: none; border: none; padding: 0; cursor: pointer;