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