@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,425 +0,0 @@
|
|
|
1
|
-
"use client";
|
|
2
|
-
|
|
3
|
-
import { useSearchParams } from "next/navigation";
|
|
4
|
-
import { type FormEvent, useEffect, useMemo, useState } from "react";
|
|
5
|
-
import type { TicketThread, Message } from "@/lib/vicket";
|
|
6
|
-
import { fetchThread, sendReply, sanitizeHtml, formatDate, cn, isFileAnswer, formatAnswerText, AUTHOR_LABELS } from "@/lib/vicket";
|
|
7
|
-
|
|
8
|
-
/* ── Custom hook ─────────────────────────────────── */
|
|
9
|
-
|
|
10
|
-
function useVicketTicket() {
|
|
11
|
-
const searchParams = useSearchParams();
|
|
12
|
-
const token = searchParams.get("token") || "";
|
|
13
|
-
|
|
14
|
-
const [thread, setThread] = useState<TicketThread | null>(null);
|
|
15
|
-
const [content, setContent] = useState("");
|
|
16
|
-
const [files, setFiles] = useState<File[]>([]);
|
|
17
|
-
const [isLoading, setIsLoading] = useState(true);
|
|
18
|
-
const [isSending, setIsSending] = useState(false);
|
|
19
|
-
const [error, setError] = useState("");
|
|
20
|
-
const [success, setSuccess] = useState("");
|
|
21
|
-
|
|
22
|
-
const hasToken = useMemo(() => token.trim().length > 0, [token]);
|
|
23
|
-
|
|
24
|
-
const loadThread = async () => {
|
|
25
|
-
if (!hasToken) {
|
|
26
|
-
setIsLoading(false);
|
|
27
|
-
setError("Missing ticket token in URL.");
|
|
28
|
-
return;
|
|
29
|
-
}
|
|
30
|
-
setIsLoading(true);
|
|
31
|
-
setError("");
|
|
32
|
-
try {
|
|
33
|
-
const data = await fetchThread(token);
|
|
34
|
-
setThread(data);
|
|
35
|
-
} catch (loadError) {
|
|
36
|
-
setError(loadError instanceof Error ? loadError.message : "Unexpected error.");
|
|
37
|
-
} finally {
|
|
38
|
-
setIsLoading(false);
|
|
39
|
-
}
|
|
40
|
-
};
|
|
41
|
-
|
|
42
|
-
useEffect(() => {
|
|
43
|
-
void loadThread();
|
|
44
|
-
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
45
|
-
}, [token]);
|
|
46
|
-
|
|
47
|
-
const firstReporterMessage = useMemo(() => {
|
|
48
|
-
if (!thread?.messages || thread.messages.length === 0) return null;
|
|
49
|
-
const sorted = [...thread.messages].sort(
|
|
50
|
-
(a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime(),
|
|
51
|
-
);
|
|
52
|
-
// Only treat as description if the very first message is from the reporter
|
|
53
|
-
return sorted[0].author_type === "reporter" ? sorted[0] : null;
|
|
54
|
-
}, [thread]);
|
|
55
|
-
|
|
56
|
-
const sortedMessages = useMemo(() => {
|
|
57
|
-
if (!thread?.messages) return [];
|
|
58
|
-
return [...thread.messages]
|
|
59
|
-
.sort((a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime())
|
|
60
|
-
.filter((m) => !firstReporterMessage || m.id !== firstReporterMessage.id);
|
|
61
|
-
}, [thread, firstReporterMessage]);
|
|
62
|
-
|
|
63
|
-
const summaryAnswers = useMemo(() => {
|
|
64
|
-
if (!thread?.answers) return [];
|
|
65
|
-
return thread.answers.filter((answer) => {
|
|
66
|
-
if (answer.attachments && answer.attachments.length > 0) return true;
|
|
67
|
-
if (answer.answer && answer.answer.trim().length > 0) return true;
|
|
68
|
-
return false;
|
|
69
|
-
});
|
|
70
|
-
}, [thread]);
|
|
71
|
-
|
|
72
|
-
const removeFile = (index: number) => {
|
|
73
|
-
setFiles((prev) => prev.filter((_, i) => i !== index));
|
|
74
|
-
};
|
|
75
|
-
|
|
76
|
-
const onSubmitReply = async (event: FormEvent<HTMLFormElement>) => {
|
|
77
|
-
event.preventDefault();
|
|
78
|
-
setError("");
|
|
79
|
-
setSuccess("");
|
|
80
|
-
if (!content.trim() && files.length === 0) {
|
|
81
|
-
setError("Reply content is required.");
|
|
82
|
-
return;
|
|
83
|
-
}
|
|
84
|
-
if (!hasToken) {
|
|
85
|
-
setError("Missing ticket token.");
|
|
86
|
-
return;
|
|
87
|
-
}
|
|
88
|
-
setIsSending(true);
|
|
89
|
-
try {
|
|
90
|
-
await sendReply(token, content.trim(), files);
|
|
91
|
-
setContent("");
|
|
92
|
-
setFiles([]);
|
|
93
|
-
setSuccess("Reply sent.");
|
|
94
|
-
await loadThread();
|
|
95
|
-
} catch (replyError) {
|
|
96
|
-
setError(replyError instanceof Error ? replyError.message : "Unexpected error.");
|
|
97
|
-
} finally {
|
|
98
|
-
setIsSending(false);
|
|
99
|
-
}
|
|
100
|
-
};
|
|
101
|
-
|
|
102
|
-
return {
|
|
103
|
-
thread, content, setContent, files, setFiles,
|
|
104
|
-
isLoading, isSending, error, setError, success, setSuccess,
|
|
105
|
-
firstReporterMessage, sortedMessages, summaryAnswers,
|
|
106
|
-
removeFile, onSubmitReply,
|
|
107
|
-
};
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
/* ── Sub-components ──────────────────────────────── */
|
|
111
|
-
|
|
112
|
-
function Alert({ type, message, onDismiss }: { type: "error" | "success"; message: string; onDismiss?: () => void }) {
|
|
113
|
-
return (
|
|
114
|
-
<div
|
|
115
|
-
className={cn(
|
|
116
|
-
"flex items-start gap-3 rounded-xl border p-4 text-sm",
|
|
117
|
-
type === "error" ? "border-red-200 bg-red-50 text-red-900" : "border-green-200 bg-green-50 text-green-900",
|
|
118
|
-
)}
|
|
119
|
-
role="alert"
|
|
120
|
-
>
|
|
121
|
-
<span className="mt-0.5 shrink-0">
|
|
122
|
-
{type === "error" ? (
|
|
123
|
-
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round"><path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3Z" /><path d="M12 9v4" /><path d="M12 17h.01" /></svg>
|
|
124
|
-
) : (
|
|
125
|
-
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round"><path d="M20 6 9 17l-5-5" /></svg>
|
|
126
|
-
)}
|
|
127
|
-
</span>
|
|
128
|
-
<span className="flex-1">{message}</span>
|
|
129
|
-
{onDismiss && (
|
|
130
|
-
<button type="button" onClick={onDismiss} className="shrink-0 cursor-pointer border-none bg-transparent p-0 opacity-50 transition-opacity hover:opacity-100" aria-label="Dismiss">
|
|
131
|
-
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round"><path d="M18 6 6 18" /><path d="m6 6 12 12" /></svg>
|
|
132
|
-
</button>
|
|
133
|
-
)}
|
|
134
|
-
</div>
|
|
135
|
-
);
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
function Avatar({ authorType }: { authorType: string }) {
|
|
139
|
-
const colors =
|
|
140
|
-
authorType === "reporter" ? "bg-blue-600/15 text-blue-600"
|
|
141
|
-
: authorType === "user" ? "bg-emerald-500/15 text-emerald-500"
|
|
142
|
-
: "bg-slate-400/20 text-slate-400";
|
|
143
|
-
|
|
144
|
-
return (
|
|
145
|
-
<div className={cn("mt-0.5 flex h-7 w-7 shrink-0 items-center justify-center rounded-full text-[10px] font-bold leading-none", colors)} aria-hidden="true">
|
|
146
|
-
{(AUTHOR_LABELS[authorType] || "?")[0]}
|
|
147
|
-
</div>
|
|
148
|
-
);
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
function MessageItem({ message }: { message: Message }) {
|
|
152
|
-
if (message.author_type === "system") {
|
|
153
|
-
return (
|
|
154
|
-
<div className="flex gap-3 px-5 py-3.5">
|
|
155
|
-
<Avatar authorType="system" />
|
|
156
|
-
<div className="min-w-0 flex-1">
|
|
157
|
-
<div className="flex flex-wrap items-baseline gap-2">
|
|
158
|
-
<span className="text-xs font-medium text-slate-500">System</span>
|
|
159
|
-
<span className="text-xs text-slate-500/50">{formatDate(message.created_at)}</span>
|
|
160
|
-
</div>
|
|
161
|
-
<p className="mt-1 text-xs italic text-slate-500">{message.content.replace(/<[^>]*>/g, "")}</p>
|
|
162
|
-
</div>
|
|
163
|
-
</div>
|
|
164
|
-
);
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
return (
|
|
168
|
-
<div className="flex gap-3 px-5 py-3.5">
|
|
169
|
-
<Avatar authorType={message.author_type} />
|
|
170
|
-
<div className="min-w-0 flex-1">
|
|
171
|
-
<div className="flex flex-wrap items-baseline gap-2">
|
|
172
|
-
<span className="text-sm font-medium text-slate-900">{AUTHOR_LABELS[message.author_type] || message.author_type}</span>
|
|
173
|
-
<span className="text-xs text-slate-500/50">{formatDate(message.created_at)}</span>
|
|
174
|
-
</div>
|
|
175
|
-
<div
|
|
176
|
-
className={cn(
|
|
177
|
-
"vk-message-content mt-1.5 break-words rounded-xl px-4 py-3 text-sm leading-relaxed",
|
|
178
|
-
message.author_type === "user" ? "border border-blue-600/10 bg-blue-50 text-slate-800" : "border border-slate-200 bg-slate-50 text-slate-800",
|
|
179
|
-
)}
|
|
180
|
-
dangerouslySetInnerHTML={{ __html: sanitizeHtml(message.content) }}
|
|
181
|
-
/>
|
|
182
|
-
{message.attachments && message.attachments.length > 0 && (
|
|
183
|
-
<div className="mt-2 flex flex-wrap gap-1.5">
|
|
184
|
-
{message.attachments.map((att) => (
|
|
185
|
-
<a key={att.id} className="inline-flex items-center gap-1.5 rounded-full border border-slate-200 bg-white px-3 py-1.5 text-xs text-slate-500 no-underline transition-all duration-150 hover:border-slate-300 hover:text-slate-900" href={att.url} rel="noopener noreferrer" target="_blank">
|
|
186
|
-
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round"><path d="m21.44 11.05-9.19 9.19a6 6 0 0 1-8.49-8.49l8.57-8.57A4 4 0 1 1 18 8.84l-8.59 8.57a2 2 0 0 1-2.83-2.83l8.49-8.48" /></svg>
|
|
187
|
-
{att.original_filename}
|
|
188
|
-
</a>
|
|
189
|
-
))}
|
|
190
|
-
</div>
|
|
191
|
-
)}
|
|
192
|
-
</div>
|
|
193
|
-
</div>
|
|
194
|
-
);
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
function ThreadSkeleton() {
|
|
198
|
-
return (
|
|
199
|
-
<div className="space-y-6">
|
|
200
|
-
<div>
|
|
201
|
-
<div className="mb-3 h-7 w-64 rounded-lg animate-pulse bg-slate-200" />
|
|
202
|
-
<div className="flex gap-2">
|
|
203
|
-
<div className="h-6 w-20 rounded-full animate-pulse bg-slate-200" />
|
|
204
|
-
<div className="h-6 w-16 rounded-full animate-pulse bg-slate-200" />
|
|
205
|
-
</div>
|
|
206
|
-
</div>
|
|
207
|
-
<div className="rounded-2xl border border-slate-200 bg-white">
|
|
208
|
-
<div className="border-b border-slate-200 px-5 py-4">
|
|
209
|
-
<div className="h-5 w-28 rounded animate-pulse bg-slate-200" />
|
|
210
|
-
</div>
|
|
211
|
-
{[1, 2, 3].map((i) => (
|
|
212
|
-
<div key={i} className="border-b border-slate-200 px-5 py-4 last:border-b-0">
|
|
213
|
-
<div className="flex gap-3">
|
|
214
|
-
<div className="h-7 w-7 shrink-0 rounded-full animate-pulse bg-slate-200" />
|
|
215
|
-
<div className="flex-1 space-y-2">
|
|
216
|
-
<div className="h-4 w-32 rounded animate-pulse bg-slate-200" />
|
|
217
|
-
<div className="h-14 w-full rounded-lg animate-pulse bg-slate-200" />
|
|
218
|
-
</div>
|
|
219
|
-
</div>
|
|
220
|
-
</div>
|
|
221
|
-
))}
|
|
222
|
-
</div>
|
|
223
|
-
</div>
|
|
224
|
-
);
|
|
225
|
-
}
|
|
226
|
-
|
|
227
|
-
function Badge({ label, variant }: { label: string; variant?: "status" | "priority" | "id" }) {
|
|
228
|
-
const colors =
|
|
229
|
-
variant === "priority" ? "border-amber-200 bg-amber-50 text-amber-700"
|
|
230
|
-
: variant === "id" ? "border-slate-200 bg-slate-50 font-mono text-slate-500"
|
|
231
|
-
: "border-blue-600/20 bg-blue-50 text-blue-600";
|
|
232
|
-
return (
|
|
233
|
-
<span className={cn("inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold", colors)}>
|
|
234
|
-
{label}
|
|
235
|
-
</span>
|
|
236
|
-
);
|
|
237
|
-
}
|
|
238
|
-
|
|
239
|
-
/* ── Main component ──────────────────────────────── */
|
|
240
|
-
|
|
241
|
-
export default function TicketPage() {
|
|
242
|
-
const {
|
|
243
|
-
thread, content, setContent, files, setFiles,
|
|
244
|
-
isLoading, isSending, error, setError, success, setSuccess,
|
|
245
|
-
firstReporterMessage, sortedMessages, summaryAnswers,
|
|
246
|
-
removeFile, onSubmitReply,
|
|
247
|
-
} = useVicketTicket();
|
|
248
|
-
|
|
249
|
-
return (
|
|
250
|
-
<div className="min-h-screen bg-slate-50 font-[system-ui,-apple-system,sans-serif] antialiased">
|
|
251
|
-
<div className="mx-auto w-full max-w-3xl px-4 py-8">
|
|
252
|
-
<div className="mb-6">
|
|
253
|
-
<a href="/support" className="inline-flex items-center gap-1.5 text-sm font-medium text-slate-500 no-underline transition-colors hover:text-slate-900">
|
|
254
|
-
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round"><path d="m12 19-7-7 7-7" /><path d="M19 12H5" /></svg>
|
|
255
|
-
Back to support
|
|
256
|
-
</a>
|
|
257
|
-
</div>
|
|
258
|
-
|
|
259
|
-
{error && <div className="mb-4"><Alert type="error" message={error} onDismiss={() => setError("")} /></div>}
|
|
260
|
-
{success && <div className="mb-4"><Alert type="success" message={success} onDismiss={() => setSuccess("")} /></div>}
|
|
261
|
-
|
|
262
|
-
{isLoading && <ThreadSkeleton />}
|
|
263
|
-
|
|
264
|
-
{!isLoading && thread && (
|
|
265
|
-
<div className="space-y-6">
|
|
266
|
-
{/* Header */}
|
|
267
|
-
<div>
|
|
268
|
-
<div className="flex flex-wrap items-start justify-between gap-3">
|
|
269
|
-
<h1 className="m-0 text-xl font-semibold tracking-tight text-slate-900 sm:text-2xl">{thread.title}</h1>
|
|
270
|
-
{thread.id && <Badge label={`#${thread.id.slice(0, 8)}`} variant="id" />}
|
|
271
|
-
</div>
|
|
272
|
-
{((thread.status?.label && thread.status.label.toLowerCase() !== "open") || (thread.priority?.label && thread.priority.label.toLowerCase() !== "low")) && (
|
|
273
|
-
<div className="mt-2.5 flex flex-wrap items-center gap-2">
|
|
274
|
-
{thread.status?.label && thread.status.label.toLowerCase() !== "open" && <Badge label={thread.status.label} variant="status" />}
|
|
275
|
-
{thread.priority?.label && thread.priority.label.toLowerCase() !== "low" && <Badge label={thread.priority.label} variant="priority" />}
|
|
276
|
-
</div>
|
|
277
|
-
)}
|
|
278
|
-
</div>
|
|
279
|
-
|
|
280
|
-
{/* Summary */}
|
|
281
|
-
{summaryAnswers.length > 0 && (
|
|
282
|
-
<div className="rounded-2xl border border-slate-200 bg-white shadow-sm">
|
|
283
|
-
<div className="flex items-center gap-2 border-b border-slate-200 px-5 py-3.5">
|
|
284
|
-
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round" className="text-slate-500"><path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z" /><path d="M14 2v4a2 2 0 0 0 2 2h4" /></svg>
|
|
285
|
-
<h2 className="m-0 text-sm font-semibold text-slate-900">Summary</h2>
|
|
286
|
-
</div>
|
|
287
|
-
<div className="space-y-3 p-5">
|
|
288
|
-
{summaryAnswers.map((answer) => (
|
|
289
|
-
<div key={answer.id} className="rounded-xl bg-slate-50/70 p-4">
|
|
290
|
-
<p className="mb-2.5 text-xs font-medium text-slate-500">{answer.question_label || "Question"}</p>
|
|
291
|
-
{answer.attachments && answer.attachments.length > 0 ? (
|
|
292
|
-
<div className="flex flex-wrap gap-1.5">
|
|
293
|
-
{answer.attachments.map((att) => (
|
|
294
|
-
<a key={att.id} href={att.url} target="_blank" rel="noopener noreferrer" className="inline-flex items-center gap-1.5 rounded-full border border-slate-200 bg-white px-3 py-1.5 text-xs text-slate-500 no-underline transition-colors hover:border-slate-300 hover:text-slate-900">
|
|
295
|
-
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round"><path d="m21.44 11.05-9.19 9.19a6 6 0 0 1-8.49-8.49l8.57-8.57A4 4 0 1 1 18 8.84l-8.59 8.57a2 2 0 0 1-2.83-2.83l8.49-8.48" /></svg>
|
|
296
|
-
{att.original_filename}
|
|
297
|
-
</a>
|
|
298
|
-
))}
|
|
299
|
-
</div>
|
|
300
|
-
) : isFileAnswer(answer.answer) ? (
|
|
301
|
-
<p className="text-sm italic text-slate-500">File uploaded</p>
|
|
302
|
-
) : (
|
|
303
|
-
<p className="whitespace-pre-wrap break-words rounded-md border border-slate-200/60 bg-white px-3 py-2 text-sm leading-relaxed text-slate-900">
|
|
304
|
-
{formatAnswerText(answer.answer) || "-"}
|
|
305
|
-
</p>
|
|
306
|
-
)}
|
|
307
|
-
</div>
|
|
308
|
-
))}
|
|
309
|
-
</div>
|
|
310
|
-
</div>
|
|
311
|
-
)}
|
|
312
|
-
|
|
313
|
-
{/* Description */}
|
|
314
|
-
{firstReporterMessage && (
|
|
315
|
-
<div className="rounded-2xl border border-slate-200 bg-white shadow-sm">
|
|
316
|
-
<div className="flex items-center gap-2 border-b border-slate-200 px-5 py-3.5">
|
|
317
|
-
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round" className="text-slate-500"><path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z" /><path d="M14 2v4a2 2 0 0 0 2 2h4" /></svg>
|
|
318
|
-
<h2 className="m-0 text-sm font-semibold text-slate-900">Description</h2>
|
|
319
|
-
</div>
|
|
320
|
-
<div className="p-5">
|
|
321
|
-
<div className="vk-message-content text-sm leading-relaxed text-slate-500" dangerouslySetInnerHTML={{ __html: sanitizeHtml(firstReporterMessage.content) }} />
|
|
322
|
-
{firstReporterMessage.attachments && firstReporterMessage.attachments.length > 0 && (
|
|
323
|
-
<div className="mt-3 flex flex-wrap gap-1.5">
|
|
324
|
-
{firstReporterMessage.attachments.map((att) => (
|
|
325
|
-
<a key={att.id} href={att.url} target="_blank" rel="noopener noreferrer" className="inline-flex items-center gap-1.5 rounded-full border border-slate-200 bg-slate-50 px-3 py-1.5 text-xs text-slate-500 no-underline transition-colors hover:border-slate-300 hover:text-slate-900">
|
|
326
|
-
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round"><path d="m21.44 11.05-9.19 9.19a6 6 0 0 1-8.49-8.49l8.57-8.57A4 4 0 1 1 18 8.84l-8.59 8.57a2 2 0 0 1-2.83-2.83l8.49-8.48" /></svg>
|
|
327
|
-
{att.original_filename}
|
|
328
|
-
</a>
|
|
329
|
-
))}
|
|
330
|
-
</div>
|
|
331
|
-
)}
|
|
332
|
-
</div>
|
|
333
|
-
</div>
|
|
334
|
-
)}
|
|
335
|
-
|
|
336
|
-
{/* Comments */}
|
|
337
|
-
<div className="rounded-2xl border border-slate-200 bg-white shadow-sm">
|
|
338
|
-
<div className="flex items-center gap-2 border-b border-slate-200 px-5 py-3.5">
|
|
339
|
-
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round" className="text-slate-500"><path d="M7.9 20A9 9 0 1 0 4 16.1L2 22Z" /></svg>
|
|
340
|
-
<h2 className="m-0 text-sm font-semibold text-slate-900">Comments</h2>
|
|
341
|
-
</div>
|
|
342
|
-
|
|
343
|
-
<div className="border-b border-slate-200 p-5">
|
|
344
|
-
<form className="space-y-3" onSubmit={onSubmitReply}>
|
|
345
|
-
<textarea
|
|
346
|
-
className="min-h-[80px] w-full resize-y rounded-lg border border-slate-200 bg-slate-50 px-3 py-2.5 text-sm text-slate-900 transition-all duration-150 placeholder:text-slate-500/60 focus:border-blue-600 focus:outline-none focus:ring-3 focus:ring-blue-600/10"
|
|
347
|
-
value={content}
|
|
348
|
-
onChange={(e) => setContent(e.target.value)}
|
|
349
|
-
placeholder="Write your reply..."
|
|
350
|
-
/>
|
|
351
|
-
<div className="flex flex-wrap items-center justify-between gap-3">
|
|
352
|
-
<div className="flex flex-wrap items-center gap-2">
|
|
353
|
-
<label className="inline-flex cursor-pointer items-center gap-1.5 rounded-lg border border-slate-200 bg-slate-50 px-3 py-1.5 text-xs font-medium text-slate-500 transition-colors hover:border-slate-300 hover:text-slate-900">
|
|
354
|
-
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round"><path d="m21.44 11.05-9.19 9.19a6 6 0 0 1-8.49-8.49l8.57-8.57A4 4 0 1 1 18 8.84l-8.59 8.57a2 2 0 0 1-2.83-2.83l8.49-8.48" /></svg>
|
|
355
|
-
Browse files
|
|
356
|
-
<input
|
|
357
|
-
type="file"
|
|
358
|
-
className="hidden"
|
|
359
|
-
multiple
|
|
360
|
-
accept="image/jpeg,image/png,image/gif,image/webp"
|
|
361
|
-
onChange={(e) => {
|
|
362
|
-
const ALLOWED_TYPES = ["image/png","image/jpeg","image/gif","image/webp"];
|
|
363
|
-
const newFiles = Array.from(e.target.files || []).filter((f) => ALLOWED_TYPES.includes(f.type));
|
|
364
|
-
setFiles((prev) => [...prev, ...newFiles]);
|
|
365
|
-
e.target.value = "";
|
|
366
|
-
}}
|
|
367
|
-
/>
|
|
368
|
-
</label>
|
|
369
|
-
{files.map((file, i) => (
|
|
370
|
-
<span key={`${file.name}-${i}`} className="inline-flex items-center gap-1.5 rounded-full border border-slate-200 bg-slate-50 px-2.5 py-1 text-xs text-slate-500">
|
|
371
|
-
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round"><path d="m21.44 11.05-9.19 9.19a6 6 0 0 1-8.49-8.49l8.57-8.57A4 4 0 1 1 18 8.84l-8.59 8.57a2 2 0 0 1-2.83-2.83l8.49-8.48" /></svg>
|
|
372
|
-
<span className="max-w-[120px] truncate">{file.name}</span>
|
|
373
|
-
<button type="button" onClick={() => removeFile(i)} className="flex cursor-pointer items-center border-none bg-transparent p-0 text-slate-500 transition-colors hover:text-red-600" aria-label={`Remove ${file.name}`}>
|
|
374
|
-
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round"><path d="M18 6 6 18" /><path d="m6 6 12 12" /></svg>
|
|
375
|
-
</button>
|
|
376
|
-
</span>
|
|
377
|
-
))}
|
|
378
|
-
</div>
|
|
379
|
-
<button className="inline-flex items-center gap-2 rounded-lg border-none bg-blue-600 px-5 py-2.5 text-sm font-semibold text-white cursor-pointer transition-all hover:bg-blue-700 hover:-translate-y-px hover:shadow-lg active:translate-y-0 disabled:opacity-60 disabled:cursor-not-allowed" disabled={isSending} type="submit">
|
|
380
|
-
{isSending ? (
|
|
381
|
-
<>
|
|
382
|
-
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round" className="animate-spin"><path d="M21 12a9 9 0 1 1-6.219-8.56" /></svg>
|
|
383
|
-
Sending...
|
|
384
|
-
</>
|
|
385
|
-
) : (
|
|
386
|
-
<>
|
|
387
|
-
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round"><path d="M14.536 21.686a.5.5 0 0 0 .937-.024l6.5-19a.496.496 0 0 0-.635-.635l-19 6.5a.5.5 0 0 0-.024.937l7.93 3.18a2 2 0 0 1 1.112 1.11z" /><path d="m21.854 2.147-10.94 10.939" /></svg>
|
|
388
|
-
Send
|
|
389
|
-
</>
|
|
390
|
-
)}
|
|
391
|
-
</button>
|
|
392
|
-
</div>
|
|
393
|
-
</form>
|
|
394
|
-
</div>
|
|
395
|
-
|
|
396
|
-
{sortedMessages.length === 0 ? (
|
|
397
|
-
<div className="px-5 py-12 text-center">
|
|
398
|
-
<div className="mx-auto mb-3 flex h-10 w-10 items-center justify-center rounded-full bg-slate-50 text-slate-500">
|
|
399
|
-
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round"><path d="M7.9 20A9 9 0 1 0 4 16.1L2 22Z" /></svg>
|
|
400
|
-
</div>
|
|
401
|
-
<p className="m-0 text-sm text-slate-500">No messages yet. Be the first to reply!</p>
|
|
402
|
-
</div>
|
|
403
|
-
) : (
|
|
404
|
-
<div className="divide-y divide-slate-200">
|
|
405
|
-
{sortedMessages.map((message) => (
|
|
406
|
-
<MessageItem key={message.id} message={message} />
|
|
407
|
-
))}
|
|
408
|
-
</div>
|
|
409
|
-
)}
|
|
410
|
-
</div>
|
|
411
|
-
</div>
|
|
412
|
-
)}
|
|
413
|
-
</div>
|
|
414
|
-
|
|
415
|
-
<style>{`
|
|
416
|
-
.vk-message-content p { margin-bottom: 0.5rem; }
|
|
417
|
-
.vk-message-content p:last-child { margin-bottom: 0; }
|
|
418
|
-
.vk-message-content a { color: #2563eb; text-decoration: underline; }
|
|
419
|
-
.vk-message-content ul, .vk-message-content ol { margin: 0.25rem 0; padding-left: 1.5rem; }
|
|
420
|
-
.vk-message-content pre { overflow-x: auto; border-radius: 6px; background: #f8fafc; padding: 0.75rem; font-size: 0.75rem; }
|
|
421
|
-
.vk-message-content code { font-family: monospace; font-size: 0.85em; }
|
|
422
|
-
`}</style>
|
|
423
|
-
</div>
|
|
424
|
-
);
|
|
425
|
-
}
|