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 = ""; }