@vicket/create-support 1.1.1 → 1.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/create-vicket-support.js +429 -389
- package/package.json +1 -1
- package/templates/next/src/app/api/vicket/[...path]/route.ts +2 -55
- package/templates/next/src/app/components/vicket/ReplyForm.tsx +154 -0
- package/templates/next/src/app/components/vicket/SupportContent.tsx +298 -0
- package/templates/next/src/app/components/vicket/TicketDialog.tsx +3 -3
- package/templates/next/src/app/support/page.tsx +27 -353
- package/templates/next/src/app/ticket/page.tsx +110 -325
- package/templates/next/src/app/vicket.css +1325 -1325
- package/templates/nuxt/app/assets/css/vicket.css +1325 -1325
- package/templates/nuxt/app/components/VicketReplyForm.vue +154 -0
- package/templates/nuxt/app/components/VicketSupportContent.vue +255 -0
- package/templates/nuxt/app/components/VicketTicketDialog.vue +2 -2
- package/templates/nuxt/app/pages/support.vue +7 -293
- package/templates/nuxt/app/pages/ticket.vue +36 -178
- package/templates/nuxt/server/api/vicket/[...path].ts +2 -85
- package/templates/sveltekit/src/lib/vicket/ReplyForm.svelte +134 -0
- package/templates/sveltekit/src/lib/vicket/SupportContent.svelte +263 -0
- package/templates/sveltekit/src/lib/vicket/TicketDialog.svelte +457 -459
- package/templates/sveltekit/src/lib/vicket.css +1325 -1325
- package/templates/sveltekit/src/routes/api/vicket/[...path]/+server.ts +2 -76
- package/templates/sveltekit/src/routes/support/+page.server.ts +13 -0
- package/templates/sveltekit/src/routes/support/+page.svelte +3 -312
- package/templates/sveltekit/src/routes/ticket/+page.server.ts +19 -0
- package/templates/sveltekit/src/routes/ticket/+page.svelte +13 -188
- package/templates-tailwind/next/src/app/api/vicket/[...path]/route.ts +6 -0
- package/templates-tailwind/next/src/app/support/page.tsx +33 -3
- package/templates-tailwind/next/src/app/ticket/page.tsx +249 -6
- package/templates-tailwind/next/src/components/vicket/reply-form.tsx +113 -0
- package/templates-tailwind/next/src/components/vicket/support-content.tsx +265 -0
- package/templates-tailwind/next/src/components/vicket/ticket-dialog.tsx +2 -2
- package/templates-tailwind/nuxt/app/components/VicketReplyForm.vue +169 -0
- package/templates-tailwind/nuxt/app/components/{VicketSupportPage.vue → VicketSupportContent.vue} +275 -317
- package/templates-tailwind/nuxt/app/components/VicketTicketDialog.vue +3 -0
- package/templates-tailwind/nuxt/app/pages/support.vue +10 -1
- package/templates-tailwind/nuxt/app/pages/ticket.vue +298 -1
- package/templates-tailwind/nuxt/server/api/vicket/[...path].ts +2 -0
- package/templates-tailwind/sveltekit/src/lib/vicket/ReplyForm.svelte +127 -0
- package/templates-tailwind/sveltekit/src/lib/vicket/{SupportPage.svelte → SupportContent.svelte} +9 -71
- package/templates-tailwind/sveltekit/src/lib/vicket/TicketDialog.svelte +405 -406
- package/templates-tailwind/sveltekit/src/routes/api/vicket/[...path]/+server.ts +3 -0
- package/templates-tailwind/sveltekit/src/routes/support/+page.server.ts +13 -0
- package/templates-tailwind/sveltekit/src/routes/support/+page.svelte +4 -2
- package/templates-tailwind/sveltekit/src/routes/ticket/+page.server.ts +19 -0
- package/templates-tailwind/sveltekit/src/routes/ticket/+page.svelte +292 -2
- package/templates/next/src/app/utils/vicket/api.ts +0 -149
- package/templates/next/src/app/utils/vicket/types.ts +0 -85
- package/templates/next/src/app/utils/vicket/utils.ts +0 -49
- package/templates/nuxt/app/composables/useVicket.ts +0 -274
- package/templates/sveltekit/src/lib/vicket/api.ts +0 -162
- package/templates/sveltekit/src/lib/vicket/types.ts +0 -87
- package/templates/sveltekit/src/lib/vicket/utils.ts +0 -55
- package/templates-tailwind/next/src/app/api/vicket/init/route.ts +0 -24
- package/templates-tailwind/next/src/app/api/vicket/messages/route.ts +0 -36
- package/templates-tailwind/next/src/app/api/vicket/thread/route.ts +0 -27
- package/templates-tailwind/next/src/app/api/vicket/tickets/route.ts +0 -37
- package/templates-tailwind/next/src/components/vicket/support-page.tsx +0 -359
- package/templates-tailwind/next/src/components/vicket/ticket-page.tsx +0 -425
- package/templates-tailwind/next/src/lib/vicket.ts +0 -257
- package/templates-tailwind/nuxt/app/components/VicketTicketPage.vue +0 -449
- package/templates-tailwind/nuxt/app/composables/use-vicket.ts +0 -249
- package/templates-tailwind/nuxt/server/api/vicket/init.get.ts +0 -22
- package/templates-tailwind/nuxt/server/api/vicket/messages.post.ts +0 -56
- package/templates-tailwind/nuxt/server/api/vicket/thread.get.ts +0 -26
- package/templates-tailwind/nuxt/server/api/vicket/tickets.post.ts +0 -53
- package/templates-tailwind/sveltekit/src/lib/vicket/TicketPage.svelte +0 -465
- package/templates-tailwind/sveltekit/src/lib/vicket/index.ts +0 -257
- package/templates-tailwind/sveltekit/src/routes/api/vicket/init/+server.ts +0 -22
- package/templates-tailwind/sveltekit/src/routes/api/vicket/messages/+server.ts +0 -40
- package/templates-tailwind/sveltekit/src/routes/api/vicket/thread/+server.ts +0 -25
- package/templates-tailwind/sveltekit/src/routes/api/vicket/tickets/+server.ts +0 -37
|
@@ -1,276 +1,55 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
import {
|
|
4
|
-
import { type FormEvent, useEffect, useMemo, useState } from "react";
|
|
1
|
+
import { createServerClient } from "vicket/server";
|
|
2
|
+
import type { TicketThread } from "vicket";
|
|
3
|
+
import { cn, sanitizeHtml, stripHtml, formatDate, isFileAnswer, formatAnswerText, AUTHOR_LABELS } from "vicket";
|
|
5
4
|
import "../vicket.css";
|
|
6
|
-
import
|
|
7
|
-
import { cn, sanitizeHtml, stripHtml, formatDate, isFileAnswer, formatAnswerText } from "../utils/vicket/utils";
|
|
8
|
-
import { AUTHOR_LABELS, fetchTicketThread, sendReply } from "../utils/vicket/api";
|
|
9
|
-
|
|
10
|
-
/* ---------------------------------------------- */
|
|
11
|
-
/* Skeleton loader */
|
|
12
|
-
/* ---------------------------------------------- */
|
|
13
|
-
function ThreadSkeleton() {
|
|
14
|
-
return (
|
|
15
|
-
<div className="vk-stack vk-animate-in">
|
|
16
|
-
{/* Header skeleton */}
|
|
17
|
-
<div>
|
|
18
|
-
<div className="vk-skeleton" aria-hidden="true">
|
|
19
|
-
|
|
20
|
-
</div>
|
|
21
|
-
<div className="vk-compose-files">
|
|
22
|
-
<span className="vk-skeleton" aria-hidden="true"> </span>
|
|
23
|
-
<span className="vk-skeleton" aria-hidden="true"> </span>
|
|
24
|
-
</div>
|
|
25
|
-
</div>
|
|
26
|
-
{/* Messages skeleton */}
|
|
27
|
-
<div className="vk-section-card">
|
|
28
|
-
<div className="vk-section-header">
|
|
29
|
-
<span className="vk-skeleton" aria-hidden="true"> </span>
|
|
30
|
-
</div>
|
|
31
|
-
{[1, 2, 3].map((i) => (
|
|
32
|
-
<div key={i} className="vk-message">
|
|
33
|
-
<div className="vk-avatar vk-skeleton" aria-hidden="true"> </div>
|
|
34
|
-
<div className="vk-message-content">
|
|
35
|
-
<div className="vk-skeleton" aria-hidden="true"> </div>
|
|
36
|
-
<div className="vk-skeleton" aria-hidden="true"> </div>
|
|
37
|
-
</div>
|
|
38
|
-
</div>
|
|
39
|
-
))}
|
|
40
|
-
</div>
|
|
41
|
-
</div>
|
|
42
|
-
);
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
/* ---------------------------------------------- */
|
|
46
|
-
/* Alert component */
|
|
47
|
-
/* ---------------------------------------------- */
|
|
48
|
-
function Alert({
|
|
49
|
-
type,
|
|
50
|
-
message,
|
|
51
|
-
onDismiss,
|
|
52
|
-
}: {
|
|
53
|
-
type: "error" | "success";
|
|
54
|
-
message: string;
|
|
55
|
-
onDismiss?: () => void;
|
|
56
|
-
}) {
|
|
57
|
-
return (
|
|
58
|
-
<div
|
|
59
|
-
className={cn("vk-alert vk-slide-up", type === "error" ? "error" : "success")}
|
|
60
|
-
role="alert"
|
|
61
|
-
>
|
|
62
|
-
<span>{type === "error" ? "\u26A0" : "\u2713"}</span>
|
|
63
|
-
<span>{message}</span>
|
|
64
|
-
{onDismiss && (
|
|
65
|
-
<button
|
|
66
|
-
type="button"
|
|
67
|
-
onClick={onDismiss}
|
|
68
|
-
className="vk-alert-dismiss"
|
|
69
|
-
aria-label="Dismiss"
|
|
70
|
-
>
|
|
71
|
-
✕
|
|
72
|
-
</button>
|
|
73
|
-
)}
|
|
74
|
-
</div>
|
|
75
|
-
);
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
/* ---------------------------------------------- */
|
|
79
|
-
/* Badge component */
|
|
80
|
-
/* ---------------------------------------------- */
|
|
81
|
-
function Badge({
|
|
82
|
-
label,
|
|
83
|
-
variant,
|
|
84
|
-
}: {
|
|
85
|
-
label: string;
|
|
86
|
-
variant?: "status" | "priority" | "id";
|
|
87
|
-
}) {
|
|
88
|
-
return (
|
|
89
|
-
<span className={cn("vk-badge", variant || "status")}>
|
|
90
|
-
{label}
|
|
91
|
-
</span>
|
|
92
|
-
);
|
|
93
|
-
}
|
|
5
|
+
import ReplyForm from "../components/vicket/ReplyForm";
|
|
94
6
|
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
function Avatar({ authorType }: { authorType: string }) {
|
|
99
|
-
return (
|
|
100
|
-
<div
|
|
101
|
-
className={cn("vk-avatar", `vk-avatar-${authorType}`)}
|
|
102
|
-
aria-hidden="true"
|
|
103
|
-
>
|
|
104
|
-
{(AUTHOR_LABELS[authorType] || "?")[0]}
|
|
105
|
-
</div>
|
|
106
|
-
);
|
|
107
|
-
}
|
|
7
|
+
type Props = {
|
|
8
|
+
searchParams: Promise<{ [key: string]: string | string[] | undefined }>;
|
|
9
|
+
};
|
|
108
10
|
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
function MessageItem({ message }: { message: Message }) {
|
|
113
|
-
const isSystem = message.author_type === "system";
|
|
114
|
-
const isSupport = message.author_type === "user";
|
|
11
|
+
export default async function TicketPage({ searchParams }: Props) {
|
|
12
|
+
const { token } = await searchParams;
|
|
13
|
+
const tokenStr = typeof token === "string" ? token : "";
|
|
115
14
|
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
<div className="vk-message">
|
|
119
|
-
<Avatar authorType="system" />
|
|
120
|
-
<div className="vk-message-content">
|
|
121
|
-
<div className="vk-message-meta">
|
|
122
|
-
<span className="vk-author-name system">System</span>
|
|
123
|
-
<span className="vk-message-time">{formatDate(message.created_at)}</span>
|
|
124
|
-
</div>
|
|
125
|
-
<p className="vk-system-text">
|
|
126
|
-
{stripHtml(message.content)}
|
|
127
|
-
</p>
|
|
128
|
-
</div>
|
|
129
|
-
</div>
|
|
130
|
-
);
|
|
131
|
-
}
|
|
15
|
+
let thread: TicketThread | null = null;
|
|
16
|
+
let fetchError = "";
|
|
132
17
|
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
<div className="vk-message-content">
|
|
137
|
-
<div className="vk-message-meta">
|
|
138
|
-
<span className="vk-author-name">
|
|
139
|
-
{AUTHOR_LABELS[message.author_type] || message.author_type}
|
|
140
|
-
</span>
|
|
141
|
-
<span className="vk-message-time">{formatDate(message.created_at)}</span>
|
|
142
|
-
</div>
|
|
143
|
-
<div
|
|
144
|
-
className={cn(
|
|
145
|
-
"vk-message-bubble",
|
|
146
|
-
isSupport && "support",
|
|
147
|
-
)}
|
|
148
|
-
>
|
|
149
|
-
<div
|
|
150
|
-
className="vk-message-html"
|
|
151
|
-
dangerouslySetInnerHTML={{
|
|
152
|
-
__html: sanitizeHtml(message.content),
|
|
153
|
-
}}
|
|
154
|
-
/>
|
|
155
|
-
</div>
|
|
156
|
-
{message.attachments && message.attachments.length > 0 && (
|
|
157
|
-
<div className="vk-attachments">
|
|
158
|
-
{message.attachments.map((attachment) => (
|
|
159
|
-
<a
|
|
160
|
-
className="vk-attachment"
|
|
161
|
-
href={attachment.url}
|
|
162
|
-
key={attachment.id}
|
|
163
|
-
rel="noopener noreferrer"
|
|
164
|
-
target="_blank"
|
|
165
|
-
>
|
|
166
|
-
📎 <span>{attachment.original_filename}</span>
|
|
167
|
-
</a>
|
|
168
|
-
))}
|
|
169
|
-
</div>
|
|
170
|
-
)}
|
|
171
|
-
</div>
|
|
172
|
-
</div>
|
|
173
|
-
);
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
/* ---------------------------------------------- */
|
|
177
|
-
/* Main page */
|
|
178
|
-
/* ---------------------------------------------- */
|
|
179
|
-
export default function TicketPage() {
|
|
180
|
-
const searchParams = useSearchParams();
|
|
181
|
-
const token = searchParams.get("token") || "";
|
|
182
|
-
|
|
183
|
-
const [thread, setThread] = useState<TicketThread | null>(null);
|
|
184
|
-
const [content, setContent] = useState("");
|
|
185
|
-
const [files, setFiles] = useState<File[]>([]);
|
|
186
|
-
const [isLoading, setIsLoading] = useState(true);
|
|
187
|
-
const [isSending, setIsSending] = useState(false);
|
|
188
|
-
const [error, setError] = useState("");
|
|
189
|
-
const [success, setSuccess] = useState("");
|
|
190
|
-
|
|
191
|
-
const hasToken = useMemo(() => token.trim().length > 0, [token]);
|
|
192
|
-
|
|
193
|
-
const loadThread = async () => {
|
|
194
|
-
if (!hasToken) {
|
|
195
|
-
setIsLoading(false);
|
|
196
|
-
setError("Missing ticket token in URL.");
|
|
197
|
-
return;
|
|
198
|
-
}
|
|
199
|
-
|
|
200
|
-
setIsLoading(true);
|
|
201
|
-
setError("");
|
|
18
|
+
if (!tokenStr.trim()) {
|
|
19
|
+
fetchError = "Missing ticket token in URL.";
|
|
20
|
+
} else {
|
|
202
21
|
try {
|
|
203
|
-
const
|
|
204
|
-
|
|
205
|
-
} catch (
|
|
206
|
-
|
|
207
|
-
} finally {
|
|
208
|
-
setIsLoading(false);
|
|
22
|
+
const vicket = createServerClient();
|
|
23
|
+
thread = await vicket.fetchThread(tokenStr);
|
|
24
|
+
} catch (e) {
|
|
25
|
+
fetchError = e instanceof Error ? e.message : "Failed to load ticket.";
|
|
209
26
|
}
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
useEffect(() => {
|
|
213
|
-
void loadThread();
|
|
214
|
-
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
215
|
-
}, [token]);
|
|
27
|
+
}
|
|
216
28
|
|
|
217
|
-
|
|
29
|
+
/* Derive data from thread */
|
|
30
|
+
const firstReporterMessage = (() => {
|
|
218
31
|
if (!thread?.messages || thread.messages.length === 0) return null;
|
|
219
32
|
const sorted = [...thread.messages].sort(
|
|
220
33
|
(a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime(),
|
|
221
34
|
);
|
|
222
|
-
// Only treat as description if the very first message is from the reporter
|
|
223
35
|
return sorted[0].author_type === "reporter" ? sorted[0] : null;
|
|
224
|
-
}
|
|
36
|
+
})();
|
|
225
37
|
|
|
226
|
-
const sortedMessages =
|
|
38
|
+
const sortedMessages = (() => {
|
|
227
39
|
if (!thread?.messages) return [];
|
|
228
40
|
return [...thread.messages]
|
|
229
41
|
.sort((a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime())
|
|
230
42
|
.filter((m) => !firstReporterMessage || m.id !== firstReporterMessage.id);
|
|
231
|
-
}
|
|
43
|
+
})();
|
|
232
44
|
|
|
233
|
-
const summaryAnswers =
|
|
45
|
+
const summaryAnswers = (() => {
|
|
234
46
|
if (!thread?.answers) return [];
|
|
235
47
|
return thread.answers.filter((answer) => {
|
|
236
48
|
if (answer.attachments && answer.attachments.length > 0) return true;
|
|
237
49
|
if (answer.answer && answer.answer.trim().length > 0) return true;
|
|
238
50
|
return false;
|
|
239
51
|
});
|
|
240
|
-
}
|
|
241
|
-
|
|
242
|
-
const removeFile = (index: number) => {
|
|
243
|
-
setFiles((prev) => prev.filter((_, i) => i !== index));
|
|
244
|
-
};
|
|
245
|
-
|
|
246
|
-
const onSubmitReply = async (event: FormEvent<HTMLFormElement>) => {
|
|
247
|
-
event.preventDefault();
|
|
248
|
-
setError("");
|
|
249
|
-
setSuccess("");
|
|
250
|
-
|
|
251
|
-
if (!content.trim() && files.length === 0) {
|
|
252
|
-
setError("Reply content is required.");
|
|
253
|
-
return;
|
|
254
|
-
}
|
|
255
|
-
|
|
256
|
-
if (!hasToken) {
|
|
257
|
-
setError("Missing ticket token.");
|
|
258
|
-
return;
|
|
259
|
-
}
|
|
260
|
-
|
|
261
|
-
setIsSending(true);
|
|
262
|
-
try {
|
|
263
|
-
await sendReply(token, content.trim(), files);
|
|
264
|
-
setContent("");
|
|
265
|
-
setFiles([]);
|
|
266
|
-
setSuccess("Reply sent.");
|
|
267
|
-
await loadThread();
|
|
268
|
-
} catch (replyError) {
|
|
269
|
-
setError(replyError instanceof Error ? replyError.message : "Unexpected error.");
|
|
270
|
-
} finally {
|
|
271
|
-
setIsSending(false);
|
|
272
|
-
}
|
|
273
|
-
};
|
|
52
|
+
})();
|
|
274
53
|
|
|
275
54
|
return (
|
|
276
55
|
<div className="vk-shell">
|
|
@@ -282,36 +61,42 @@ export default function TicketPage() {
|
|
|
282
61
|
</a>
|
|
283
62
|
</div>
|
|
284
63
|
|
|
285
|
-
{/*
|
|
286
|
-
{
|
|
287
|
-
<
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
64
|
+
{/* Error */}
|
|
65
|
+
{fetchError && (
|
|
66
|
+
<div
|
|
67
|
+
className={cn("vk-alert vk-slide-up", "error")}
|
|
68
|
+
role="alert"
|
|
69
|
+
>
|
|
70
|
+
<span>{"\u26A0"}</span>
|
|
71
|
+
<span>{fetchError}</span>
|
|
72
|
+
</div>
|
|
291
73
|
)}
|
|
292
74
|
|
|
293
|
-
{/* Loading */}
|
|
294
|
-
{isLoading && <ThreadSkeleton />}
|
|
295
|
-
|
|
296
75
|
{/* Thread */}
|
|
297
|
-
{
|
|
76
|
+
{thread && (
|
|
298
77
|
<div className="vk-stack">
|
|
299
78
|
{/* Header */}
|
|
300
79
|
<div>
|
|
301
80
|
<div className="vk-ticket-header">
|
|
302
81
|
<h1 className="vk-ticket-title">{thread.title}</h1>
|
|
303
82
|
{thread.id && (
|
|
304
|
-
<
|
|
83
|
+
<span className={cn("vk-badge", "id")}>
|
|
84
|
+
{`#${thread.id.slice(0, 8)}`}
|
|
85
|
+
</span>
|
|
305
86
|
)}
|
|
306
87
|
</div>
|
|
307
88
|
{((thread.status?.label && thread.status.label.toLowerCase() !== "open") ||
|
|
308
89
|
(thread.priority?.label && thread.priority.label.toLowerCase() !== "low")) && (
|
|
309
90
|
<div className="vk-ticket-badges">
|
|
310
91
|
{thread.status?.label && thread.status.label.toLowerCase() !== "open" && (
|
|
311
|
-
<
|
|
92
|
+
<span className={cn("vk-badge", "status")}>
|
|
93
|
+
{thread.status.label}
|
|
94
|
+
</span>
|
|
312
95
|
)}
|
|
313
96
|
{thread.priority?.label && thread.priority.label.toLowerCase() !== "low" && (
|
|
314
|
-
<
|
|
97
|
+
<span className={cn("vk-badge", "priority")}>
|
|
98
|
+
{thread.priority.label}
|
|
99
|
+
</span>
|
|
315
100
|
)}
|
|
316
101
|
</div>
|
|
317
102
|
)}
|
|
@@ -397,64 +182,8 @@ export default function TicketPage() {
|
|
|
397
182
|
<h2>Comments</h2>
|
|
398
183
|
</div>
|
|
399
184
|
|
|
400
|
-
{/*
|
|
401
|
-
<
|
|
402
|
-
<form className="vk-stack" onSubmit={onSubmitReply}>
|
|
403
|
-
<textarea
|
|
404
|
-
className="vk-textarea"
|
|
405
|
-
value={content}
|
|
406
|
-
onChange={(e) => setContent(e.target.value)}
|
|
407
|
-
placeholder="Write your reply..."
|
|
408
|
-
/>
|
|
409
|
-
|
|
410
|
-
<div className="vk-compose-row">
|
|
411
|
-
{/* File input */}
|
|
412
|
-
<div className="vk-compose-files">
|
|
413
|
-
<label className="vk-browse-btn">
|
|
414
|
-
📎 Browse files
|
|
415
|
-
<input
|
|
416
|
-
type="file"
|
|
417
|
-
multiple
|
|
418
|
-
onChange={(e) => {
|
|
419
|
-
const newFiles = Array.from(e.target.files || []);
|
|
420
|
-
setFiles((prev) => [...prev, ...newFiles]);
|
|
421
|
-
e.target.value = "";
|
|
422
|
-
}}
|
|
423
|
-
/>
|
|
424
|
-
</label>
|
|
425
|
-
{files.map((file, i) => (
|
|
426
|
-
<span className="vk-file-chip" key={`${file.name}-${i}`}>
|
|
427
|
-
📎
|
|
428
|
-
<span className="vk-file-chip-name">{file.name}</span>
|
|
429
|
-
<button
|
|
430
|
-
type="button"
|
|
431
|
-
onClick={() => removeFile(i)}
|
|
432
|
-
aria-label={`Remove ${file.name}`}
|
|
433
|
-
>
|
|
434
|
-
✕
|
|
435
|
-
</button>
|
|
436
|
-
</span>
|
|
437
|
-
))}
|
|
438
|
-
</div>
|
|
439
|
-
|
|
440
|
-
{/* Send */}
|
|
441
|
-
<button
|
|
442
|
-
className="vk-button primary"
|
|
443
|
-
disabled={isSending}
|
|
444
|
-
type="submit"
|
|
445
|
-
>
|
|
446
|
-
{isSending ? (
|
|
447
|
-
<>
|
|
448
|
-
<span className="vk-spinner" />
|
|
449
|
-
Sending...
|
|
450
|
-
</>
|
|
451
|
-
) : (
|
|
452
|
-
"Send"
|
|
453
|
-
)}
|
|
454
|
-
</button>
|
|
455
|
-
</div>
|
|
456
|
-
</form>
|
|
457
|
-
</div>
|
|
185
|
+
{/* Reply form (client component) */}
|
|
186
|
+
<ReplyForm token={tokenStr} />
|
|
458
187
|
|
|
459
188
|
{/* Message list */}
|
|
460
189
|
{sortedMessages.length === 0 ? (
|
|
@@ -466,12 +195,68 @@ export default function TicketPage() {
|
|
|
466
195
|
</div>
|
|
467
196
|
) : (
|
|
468
197
|
<div className="vk-message-list">
|
|
469
|
-
{sortedMessages.map((message, index) =>
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
198
|
+
{sortedMessages.map((message, index) => {
|
|
199
|
+
const isSystem = message.author_type === "system";
|
|
200
|
+
const isSupport = message.author_type === "user";
|
|
201
|
+
|
|
202
|
+
return (
|
|
203
|
+
<div key={message.id}>
|
|
204
|
+
{index > 0 && <hr className="vk-message-divider" />}
|
|
205
|
+
<div className="vk-message">
|
|
206
|
+
<div
|
|
207
|
+
className={cn("vk-avatar", `vk-avatar-${message.author_type}`)}
|
|
208
|
+
aria-hidden="true"
|
|
209
|
+
>
|
|
210
|
+
{(AUTHOR_LABELS[message.author_type] || "?")[0]}
|
|
211
|
+
</div>
|
|
212
|
+
<div className="vk-message-content">
|
|
213
|
+
<div className="vk-message-meta">
|
|
214
|
+
<span className={cn("vk-author-name", isSystem && "system")}>
|
|
215
|
+
{isSystem ? "System" : (AUTHOR_LABELS[message.author_type] || message.author_type)}
|
|
216
|
+
</span>
|
|
217
|
+
<span className="vk-message-time">{formatDate(message.created_at)}</span>
|
|
218
|
+
</div>
|
|
219
|
+
{isSystem ? (
|
|
220
|
+
<p className="vk-system-text">
|
|
221
|
+
{stripHtml(message.content)}
|
|
222
|
+
</p>
|
|
223
|
+
) : (
|
|
224
|
+
<>
|
|
225
|
+
<div
|
|
226
|
+
className={cn(
|
|
227
|
+
"vk-message-bubble",
|
|
228
|
+
isSupport && "support",
|
|
229
|
+
)}
|
|
230
|
+
>
|
|
231
|
+
<div
|
|
232
|
+
className="vk-message-html"
|
|
233
|
+
dangerouslySetInnerHTML={{
|
|
234
|
+
__html: sanitizeHtml(message.content),
|
|
235
|
+
}}
|
|
236
|
+
/>
|
|
237
|
+
</div>
|
|
238
|
+
{message.attachments && message.attachments.length > 0 && (
|
|
239
|
+
<div className="vk-attachments">
|
|
240
|
+
{message.attachments.map((attachment) => (
|
|
241
|
+
<a
|
|
242
|
+
className="vk-attachment"
|
|
243
|
+
href={attachment.url}
|
|
244
|
+
key={attachment.id}
|
|
245
|
+
rel="noopener noreferrer"
|
|
246
|
+
target="_blank"
|
|
247
|
+
>
|
|
248
|
+
📎 <span>{attachment.original_filename}</span>
|
|
249
|
+
</a>
|
|
250
|
+
))}
|
|
251
|
+
</div>
|
|
252
|
+
)}
|
|
253
|
+
</>
|
|
254
|
+
)}
|
|
255
|
+
</div>
|
|
256
|
+
</div>
|
|
257
|
+
</div>
|
|
258
|
+
);
|
|
259
|
+
})}
|
|
475
260
|
</div>
|
|
476
261
|
)}
|
|
477
262
|
</div>
|