From e90988ea5cf767ff94da735424b5170f50fa2516 Mon Sep 17 00:00:00 2001
From: 3252a8 <3252a8@proton.me>
Date: Wed, 27 May 2026 08:10:08 +0300
Subject: [PATCH] feat: persist support ticket drafts
---
frontend/src/App.svelte | 2 +
.../patterns/webapp/TicketComposer.svelte | 7 ++
.../src/lib/webapp/stores/supportStore.js | 4 +-
frontend/src/lib/webapp/supportDrafts.js | 83 +++++++++++++++++++
.../src/webapp/screens/SupportScreen.svelte | 62 ++++++++++++++
.../webapp/screens/SupportTicketScreen.svelte | 27 +++++-
6 files changed, 183 insertions(+), 2 deletions(-)
create mode 100644 frontend/src/lib/webapp/supportDrafts.js
diff --git a/frontend/src/App.svelte b/frontend/src/App.svelte
index 2aab9df..b3abd5d 100644
--- a/frontend/src/App.svelte
+++ b/frontend/src/App.svelte
@@ -1933,6 +1933,7 @@
{/if}
diff --git a/frontend/src/lib/components/patterns/webapp/TicketComposer.svelte b/frontend/src/lib/components/patterns/webapp/TicketComposer.svelte
index edfe81d..8cfdc50 100644
--- a/frontend/src/lib/components/patterns/webapp/TicketComposer.svelte
+++ b/frontend/src/lib/components/patterns/webapp/TicketComposer.svelte
@@ -14,6 +14,12 @@
if (disabled || sending || !value.trim()) return;
onSend(value.trim());
}
+
+ function handleKeydown(event) {
+ if (!(event.ctrlKey || event.metaKey) || event.key !== "Enter") return;
+ event.preventDefault();
+ submit();
+ }
@@ -25,6 +31,7 @@
{placeholder}
ariaLabel={placeholder}
class="ticket-composer-textarea"
+ on:keydown={handleKeydown}
/>
{value.length}/{maxLength}
diff --git a/frontend/src/lib/webapp/stores/supportStore.js b/frontend/src/lib/webapp/stores/supportStore.js
index e39cac4..6341dfa 100644
--- a/frontend/src/lib/webapp/stores/supportStore.js
+++ b/frontend/src/lib/webapp/stores/supportStore.js
@@ -250,7 +250,7 @@ export function createSupportStore({ api, t, showToast }) {
});
if (!ticketId) {
state.update((s) => ({ ...s, sending: false }));
- return;
+ return false;
}
try {
const res = await api(`/support/tickets/${ticketId}/messages`, {
@@ -265,8 +265,10 @@ export function createSupportStore({ api, t, showToast }) {
}));
await refreshUnread();
await loadList({ silent: true, force: true });
+ return true;
} catch (error) {
showToast(error?.message || t("wa_support_send_failed"));
+ return false;
} finally {
state.update((s) => ({ ...s, sending: false }));
}
diff --git a/frontend/src/lib/webapp/supportDrafts.js b/frontend/src/lib/webapp/supportDrafts.js
new file mode 100644
index 0000000..cfadaea
--- /dev/null
+++ b/frontend/src/lib/webapp/supportDrafts.js
@@ -0,0 +1,83 @@
+const SUPPORT_DRAFT_STORAGE_PREFIX = "rw_webapp_support_draft_v1";
+const SUPPORT_DRAFT_TTL_MS = 14 * 24 * 60 * 60 * 1000;
+const DEFAULT_SCOPE = "anonymous";
+
+function safeStorage() {
+ if (typeof window === "undefined") return null;
+ try {
+ return window.localStorage || null;
+ } catch (_error) {
+ return null;
+ }
+}
+
+function draftKey(kind, scope, id = "new") {
+ return [
+ SUPPORT_DRAFT_STORAGE_PREFIX,
+ encodeURIComponent(String(kind || "draft")),
+ encodeURIComponent(String(scope || DEFAULT_SCOPE)),
+ encodeURIComponent(String(id || "new")),
+ ].join(":");
+}
+
+function normalizeDraftEnvelope(value) {
+ if (!value || typeof value !== "object") return null;
+ const updatedAt = Number(value.updatedAt || 0);
+ if (!updatedAt || Date.now() - updatedAt > SUPPORT_DRAFT_TTL_MS) return null;
+ return value.draft && typeof value.draft === "object" ? value.draft : null;
+}
+
+export function supportDraftScope(user = {}) {
+ const id = String(user?.user_id ?? user?.id ?? user?.telegram_id ?? "").trim();
+ return id || DEFAULT_SCOPE;
+}
+
+export function readSupportDraft(kind, scope, id = "new") {
+ const storage = safeStorage();
+ if (!storage) return null;
+ const key = draftKey(kind, scope, id);
+
+ try {
+ const raw = storage.getItem(key);
+ if (!raw) return null;
+
+ const draft = normalizeDraftEnvelope(JSON.parse(raw));
+ if (!draft) storage.removeItem(key);
+ return draft;
+ } catch (_error) {
+ try {
+ storage.removeItem(key);
+ } catch (_removeError) {
+ void _removeError;
+ }
+ return null;
+ }
+}
+
+export function writeSupportDraft(kind, scope, id = "new", draft = {}) {
+ const storage = safeStorage();
+ if (!storage) return;
+
+ try {
+ storage.setItem(
+ draftKey(kind, scope, id),
+ JSON.stringify({
+ updatedAt: Date.now(),
+ draft: draft && typeof draft === "object" ? draft : {},
+ })
+ );
+ } catch (_error) {
+ void _error;
+ }
+}
+
+export function clearSupportDraft(kind, scope, id = "new") {
+ const storage = safeStorage();
+ if (!storage) return;
+
+ try {
+ storage.removeItem(draftKey(kind, scope, id));
+ } catch (_error) {
+ void _error;
+ }
+}
diff --git a/frontend/src/webapp/screens/SupportScreen.svelte b/frontend/src/webapp/screens/SupportScreen.svelte
index 3b86cf0..3d1ee82 100644
--- a/frontend/src/webapp/screens/SupportScreen.svelte
+++ b/frontend/src/webapp/screens/SupportScreen.svelte
@@ -7,10 +7,17 @@
import { Skeleton } from "$components/ui/index.js";
import { TicketCard } from "$components/patterns/webapp/index.js";
import { Select, Tabs } from "$components/ui/primitives.js";
+ import {
+ clearSupportDraft,
+ readSupportDraft,
+ supportDraftScope,
+ writeSupportDraft,
+ } from "$lib/webapp/supportDrafts.js";
export let t = (key) => key;
export let maxSubjectLength = 160;
export let maxBodyLength = 4000;
+ export let user = {};
const supportStore = getContext("supportStore");
let subject = "";
@@ -18,6 +25,7 @@
let category = "other";
let priority = "normal";
let createOpen = false;
+ let loadedCreateDraftScope = "";
$: ({ tickets, loading, creating, statusFilter, counts } = $supportStore);
$: categoryOptions = [
@@ -56,14 +64,31 @@
categoryOptions.find((option) => option.value === category) || categoryOptions[0];
$: selectedPriority =
priorityOptions.find((option) => option.value === priority) || priorityOptions[0];
+ $: draftScope = supportDraftScope(user);
+ $: if (draftScope && draftScope !== loadedCreateDraftScope) {
+ loadCreateDraft(draftScope);
+ }
+ $: if (draftScope && draftScope === loadedCreateDraftScope) {
+ persistCreateDraft(draftScope, {
+ subject,
+ body,
+ category,
+ priority,
+ open: createOpen,
+ maxSubjectLength,
+ maxBodyLength,
+ });
+ }
onMount(() => {
supportStore.loadList();
});
async function createTicket() {
+ const currentDraftScope = draftScope;
const ticket = await supportStore.createTicket({ subject, body, category, priority });
if (ticket) {
+ clearSupportDraft("new", currentDraftScope);
subject = "";
body = "";
category = "other";
@@ -71,6 +96,43 @@
createOpen = false;
}
}
+
+ function optionValue(options, value, fallback) {
+ return options.some((option) => option.value === value) ? value : fallback;
+ }
+
+ function loadCreateDraft(scope) {
+ const draft = readSupportDraft("new", scope);
+ subject = typeof draft?.subject === "string" ? draft.subject.slice(0, maxSubjectLength) : "";
+ body = typeof draft?.body === "string" ? draft.body.slice(0, maxBodyLength) : "";
+ category = optionValue(categoryOptions, draft?.category, "other");
+ priority = optionValue(priorityOptions, draft?.priority, "normal");
+ createOpen = Boolean(draft?.open || subject.trim() || body.trim());
+ loadedCreateDraftScope = scope;
+ }
+
+ function persistCreateDraft(scope, draft) {
+ const draftSubject = String(draft.subject || "").slice(0, draft.maxSubjectLength);
+ const draftBody = String(draft.body || "").slice(0, draft.maxBodyLength);
+ const hasDraft =
+ Boolean(draftSubject.trim()) ||
+ Boolean(draftBody.trim()) ||
+ draft.category !== "other" ||
+ draft.priority !== "normal";
+
+ if (!hasDraft) {
+ clearSupportDraft("new", scope);
+ return;
+ }
+
+ writeSupportDraft("new", scope, "new", {
+ subject: draftSubject,
+ body: draftBody,
+ category: draft.category,
+ priority: draft.priority,
+ open: draft.open || Boolean(draftSubject.trim() || draftBody.trim()),
+ });
+ }
diff --git a/frontend/src/webapp/screens/SupportTicketScreen.svelte b/frontend/src/webapp/screens/SupportTicketScreen.svelte
index 3b7994d..037b1c7 100644
--- a/frontend/src/webapp/screens/SupportTicketScreen.svelte
+++ b/frontend/src/webapp/screens/SupportTicketScreen.svelte
@@ -4,10 +4,17 @@
import Card from "$components/ui/card.svelte";
import { ArrowLeft } from "$components/ui/icons.js";
import { TicketComposer, TicketMessageBubble } from "$components/patterns/webapp/index.js";
+ import {
+ clearSupportDraft,
+ readSupportDraft,
+ supportDraftScope,
+ writeSupportDraft,
+ } from "$lib/webapp/supportDrafts.js";
export let t = (key) => key;
export let maxBodyLength = 4000;
export let brand = {};
+ export let user = {};
export let userAvatarUrl = "";
export let userInitials = "";
@@ -15,12 +22,30 @@
let reply = "";
let messagesScrollEl;
let lastMessageKey = "";
+ let replyDraftKey = "";
$: ({ openedTicket, messages, detailLoading, sending } = $supportStore);
$: closed = ["resolved", "closed"].includes(openedTicket?.status);
+ $: ticketId = openedTicket?.ticket_id || "";
+ $: draftScope = supportDraftScope(user);
+ $: nextReplyDraftKey = ticketId ? `${draftScope}:${ticketId}` : "";
+ $: if (nextReplyDraftKey && nextReplyDraftKey !== replyDraftKey) {
+ const draft = readSupportDraft("reply", draftScope, ticketId);
+ reply = typeof draft?.body === "string" ? draft.body.slice(0, maxBodyLength) : "";
+ replyDraftKey = nextReplyDraftKey;
+ }
+ $: if (nextReplyDraftKey && replyDraftKey === nextReplyDraftKey && !closed) {
+ const body = String(reply || "").slice(0, maxBodyLength);
+ if (body.trim()) writeSupportDraft("reply", draftScope, ticketId, { body });
+ else clearSupportDraft("reply", draftScope, ticketId);
+ }
async function send(body) {
- await supportStore.sendReply(body);
+ const currentTicketId = ticketId;
+ const currentDraftScope = draftScope;
+ const sent = await supportStore.sendReply(body);
+ if (!sent) return;
+ if (currentTicketId) clearSupportDraft("reply", currentDraftScope, currentTicketId);
reply = "";
}