@vicket/create-support 1.1.1
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/README.md +52 -0
- package/bin/create-vicket-support.js +389 -0
- package/package.json +18 -0
- package/templates/next/src/app/api/vicket/[...path]/route.ts +59 -0
- package/templates/next/src/app/components/vicket/TicketDialog.tsx +514 -0
- package/templates/next/src/app/support/page.tsx +358 -0
- package/templates/next/src/app/ticket/page.tsx +483 -0
- package/templates/next/src/app/utils/vicket/api.ts +149 -0
- package/templates/next/src/app/utils/vicket/types.ts +85 -0
- package/templates/next/src/app/utils/vicket/utils.ts +49 -0
- package/templates/next/src/app/vicket.css +1325 -0
- package/templates/nuxt/app/assets/css/vicket.css +1325 -0
- package/templates/nuxt/app/components/VicketTicketDialog.vue +499 -0
- package/templates/nuxt/app/composables/useVicket.ts +274 -0
- package/templates/nuxt/app/pages/support.vue +303 -0
- package/templates/nuxt/app/pages/ticket.vue +434 -0
- package/templates/nuxt/server/api/vicket/[...path].ts +85 -0
- package/templates/sveltekit/src/lib/vicket/TicketDialog.svelte +459 -0
- package/templates/sveltekit/src/lib/vicket/api.ts +162 -0
- package/templates/sveltekit/src/lib/vicket/types.ts +87 -0
- package/templates/sveltekit/src/lib/vicket/utils.ts +55 -0
- package/templates/sveltekit/src/lib/vicket.css +1325 -0
- package/templates/sveltekit/src/routes/api/vicket/[...path]/+server.ts +77 -0
- package/templates/sveltekit/src/routes/support/+page.svelte +316 -0
- package/templates/sveltekit/src/routes/ticket/+page.svelte +418 -0
- package/templates-tailwind/next/src/app/api/vicket/init/route.ts +24 -0
- package/templates-tailwind/next/src/app/api/vicket/messages/route.ts +36 -0
- package/templates-tailwind/next/src/app/api/vicket/thread/route.ts +27 -0
- package/templates-tailwind/next/src/app/api/vicket/tickets/route.ts +37 -0
- package/templates-tailwind/next/src/app/support/page.tsx +5 -0
- package/templates-tailwind/next/src/app/ticket/page.tsx +10 -0
- package/templates-tailwind/next/src/components/vicket/support-page.tsx +359 -0
- package/templates-tailwind/next/src/components/vicket/ticket-dialog.tsx +306 -0
- package/templates-tailwind/next/src/components/vicket/ticket-page.tsx +425 -0
- package/templates-tailwind/next/src/lib/vicket.ts +257 -0
- package/templates-tailwind/nuxt/app/components/VicketSupportPage.vue +317 -0
- package/templates-tailwind/nuxt/app/components/VicketTicketDialog.vue +444 -0
- package/templates-tailwind/nuxt/app/components/VicketTicketPage.vue +449 -0
- package/templates-tailwind/nuxt/app/composables/use-vicket.ts +249 -0
- package/templates-tailwind/nuxt/app/pages/support.vue +3 -0
- package/templates-tailwind/nuxt/app/pages/ticket.vue +3 -0
- package/templates-tailwind/nuxt/server/api/vicket/init.get.ts +22 -0
- package/templates-tailwind/nuxt/server/api/vicket/messages.post.ts +56 -0
- package/templates-tailwind/nuxt/server/api/vicket/thread.get.ts +26 -0
- package/templates-tailwind/nuxt/server/api/vicket/tickets.post.ts +53 -0
- package/templates-tailwind/sveltekit/src/lib/vicket/SupportPage.svelte +395 -0
- package/templates-tailwind/sveltekit/src/lib/vicket/TicketDialog.svelte +406 -0
- package/templates-tailwind/sveltekit/src/lib/vicket/TicketPage.svelte +465 -0
- package/templates-tailwind/sveltekit/src/lib/vicket/index.ts +257 -0
- package/templates-tailwind/sveltekit/src/routes/api/vicket/init/+server.ts +22 -0
- package/templates-tailwind/sveltekit/src/routes/api/vicket/messages/+server.ts +40 -0
- package/templates-tailwind/sveltekit/src/routes/api/vicket/thread/+server.ts +25 -0
- package/templates-tailwind/sveltekit/src/routes/api/vicket/tickets/+server.ts +37 -0
- package/templates-tailwind/sveltekit/src/routes/support/+page.svelte +5 -0
- package/templates-tailwind/sveltekit/src/routes/ticket/+page.svelte +5 -0
|
@@ -0,0 +1,434 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import type { TicketThread as TicketThreadType } from "~/composables/useVicket";
|
|
3
|
+
import {
|
|
4
|
+
cn,
|
|
5
|
+
stripHtml,
|
|
6
|
+
sanitizeHtml,
|
|
7
|
+
formatDate,
|
|
8
|
+
isFileAnswer,
|
|
9
|
+
formatAnswerText,
|
|
10
|
+
AUTHOR_LABELS,
|
|
11
|
+
fetchTicketThread,
|
|
12
|
+
sendReply,
|
|
13
|
+
} from "~/composables/useVicket";
|
|
14
|
+
|
|
15
|
+
/* ---------------------------------------------- */
|
|
16
|
+
/* Reactive state */
|
|
17
|
+
/* ---------------------------------------------- */
|
|
18
|
+
const route = useRoute();
|
|
19
|
+
const token = computed(() => String(route.query.token || ""));
|
|
20
|
+
const hasToken = computed(() => token.value.trim().length > 0);
|
|
21
|
+
|
|
22
|
+
const thread = ref<TicketThreadType | null>(null);
|
|
23
|
+
const content = ref("");
|
|
24
|
+
const files = ref<File[]>([]);
|
|
25
|
+
const isLoading = ref(true);
|
|
26
|
+
const isSending = ref(false);
|
|
27
|
+
const error = ref("");
|
|
28
|
+
const success = ref("");
|
|
29
|
+
|
|
30
|
+
/* ---------------------------------------------- */
|
|
31
|
+
/* Computed */
|
|
32
|
+
/* ---------------------------------------------- */
|
|
33
|
+
const firstReporterMessage = computed(() => {
|
|
34
|
+
if (!thread.value?.messages || thread.value.messages.length === 0) return null;
|
|
35
|
+
const sorted = [...thread.value.messages].sort(
|
|
36
|
+
(a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime(),
|
|
37
|
+
);
|
|
38
|
+
// Only treat as description if the very first message is from the reporter
|
|
39
|
+
return sorted[0].author_type === "reporter" ? sorted[0] : null;
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
const sortedMessages = computed(() => {
|
|
43
|
+
if (!thread.value?.messages) return [];
|
|
44
|
+
return [...thread.value.messages]
|
|
45
|
+
.sort((a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime())
|
|
46
|
+
.filter((m) => !firstReporterMessage.value || m.id !== firstReporterMessage.value.id);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
const summaryAnswers = computed(() => {
|
|
50
|
+
if (!thread.value?.answers) return [];
|
|
51
|
+
return thread.value.answers.filter((answer) => {
|
|
52
|
+
if (answer.attachments && answer.attachments.length > 0) return true;
|
|
53
|
+
if (answer.answer && answer.answer.trim().length > 0) return true;
|
|
54
|
+
return false;
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
/* ---------------------------------------------- */
|
|
59
|
+
/* Helpers */
|
|
60
|
+
/* ---------------------------------------------- */
|
|
61
|
+
const removeFile = (index: number) => {
|
|
62
|
+
files.value = files.value.filter((_, i) => i !== index);
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
const onFileChange = (event: Event) => {
|
|
66
|
+
const input = event.target as HTMLInputElement;
|
|
67
|
+
const newFiles = Array.from(input.files || []);
|
|
68
|
+
files.value = [...files.value, ...newFiles];
|
|
69
|
+
input.value = "";
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
/* ---------------------------------------------- */
|
|
73
|
+
/* Data loading */
|
|
74
|
+
/* ---------------------------------------------- */
|
|
75
|
+
const loadThread = async () => {
|
|
76
|
+
if (!hasToken.value) {
|
|
77
|
+
isLoading.value = false;
|
|
78
|
+
error.value = "Missing ticket token in URL.";
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
isLoading.value = true;
|
|
83
|
+
error.value = "";
|
|
84
|
+
try {
|
|
85
|
+
const data = await fetchTicketThread(token.value);
|
|
86
|
+
thread.value = data;
|
|
87
|
+
} catch (loadError) {
|
|
88
|
+
error.value = loadError instanceof Error ? loadError.message : "Unexpected error.";
|
|
89
|
+
} finally {
|
|
90
|
+
isLoading.value = false;
|
|
91
|
+
}
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
const submitReply = async () => {
|
|
95
|
+
error.value = "";
|
|
96
|
+
success.value = "";
|
|
97
|
+
|
|
98
|
+
if (!content.value.trim() && files.value.length === 0) {
|
|
99
|
+
error.value = "Reply content is required.";
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (!hasToken.value) {
|
|
104
|
+
error.value = "Missing ticket token.";
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
isSending.value = true;
|
|
109
|
+
try {
|
|
110
|
+
await sendReply(token.value, content.value.trim(), files.value);
|
|
111
|
+
content.value = "";
|
|
112
|
+
files.value = [];
|
|
113
|
+
success.value = "Reply sent.";
|
|
114
|
+
await loadThread();
|
|
115
|
+
} catch (replyError) {
|
|
116
|
+
error.value = replyError instanceof Error ? replyError.message : "Unexpected error.";
|
|
117
|
+
} finally {
|
|
118
|
+
isSending.value = false;
|
|
119
|
+
}
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
watch(
|
|
123
|
+
() => token.value,
|
|
124
|
+
() => {
|
|
125
|
+
void loadThread();
|
|
126
|
+
},
|
|
127
|
+
{ immediate: true },
|
|
128
|
+
);
|
|
129
|
+
</script>
|
|
130
|
+
|
|
131
|
+
<template>
|
|
132
|
+
<div class="vk-shell">
|
|
133
|
+
<div class="vk-page vk-animate-in">
|
|
134
|
+
<!-- Back link -->
|
|
135
|
+
<div class="vk-ticket-badges">
|
|
136
|
+
<NuxtLink to="/support" class="vk-back-link">
|
|
137
|
+
← Back to support
|
|
138
|
+
</NuxtLink>
|
|
139
|
+
</div>
|
|
140
|
+
|
|
141
|
+
<!-- Alerts -->
|
|
142
|
+
<div
|
|
143
|
+
v-if="error"
|
|
144
|
+
:class="cn('vk-alert vk-slide-up', 'error')"
|
|
145
|
+
role="alert"
|
|
146
|
+
>
|
|
147
|
+
<span>⚠</span>
|
|
148
|
+
<span>{{ error }}</span>
|
|
149
|
+
<button
|
|
150
|
+
type="button"
|
|
151
|
+
class="vk-alert-dismiss"
|
|
152
|
+
aria-label="Dismiss"
|
|
153
|
+
@click="error = ''"
|
|
154
|
+
>
|
|
155
|
+
✕
|
|
156
|
+
</button>
|
|
157
|
+
</div>
|
|
158
|
+
<div
|
|
159
|
+
v-if="success"
|
|
160
|
+
:class="cn('vk-alert vk-slide-up', 'success')"
|
|
161
|
+
role="alert"
|
|
162
|
+
>
|
|
163
|
+
<span>✓</span>
|
|
164
|
+
<span>{{ success }}</span>
|
|
165
|
+
<button
|
|
166
|
+
type="button"
|
|
167
|
+
class="vk-alert-dismiss"
|
|
168
|
+
aria-label="Dismiss"
|
|
169
|
+
@click="success = ''"
|
|
170
|
+
>
|
|
171
|
+
✕
|
|
172
|
+
</button>
|
|
173
|
+
</div>
|
|
174
|
+
|
|
175
|
+
<!-- Loading skeleton -->
|
|
176
|
+
<div v-if="isLoading" class="vk-stack vk-animate-in">
|
|
177
|
+
<!-- Header skeleton -->
|
|
178
|
+
<div>
|
|
179
|
+
<div class="vk-skeleton" aria-hidden="true">
|
|
180
|
+
|
|
181
|
+
</div>
|
|
182
|
+
<div class="vk-compose-files">
|
|
183
|
+
<span class="vk-skeleton" aria-hidden="true"> </span>
|
|
184
|
+
<span class="vk-skeleton" aria-hidden="true"> </span>
|
|
185
|
+
</div>
|
|
186
|
+
</div>
|
|
187
|
+
<!-- Messages skeleton -->
|
|
188
|
+
<div class="vk-section-card">
|
|
189
|
+
<div class="vk-section-header">
|
|
190
|
+
<span class="vk-skeleton" aria-hidden="true"> </span>
|
|
191
|
+
</div>
|
|
192
|
+
<div v-for="i in 3" :key="i" class="vk-message">
|
|
193
|
+
<div class="vk-avatar vk-skeleton" aria-hidden="true"> </div>
|
|
194
|
+
<div class="vk-message-content">
|
|
195
|
+
<div class="vk-skeleton" aria-hidden="true"> </div>
|
|
196
|
+
<div class="vk-skeleton" aria-hidden="true"> </div>
|
|
197
|
+
</div>
|
|
198
|
+
</div>
|
|
199
|
+
</div>
|
|
200
|
+
</div>
|
|
201
|
+
|
|
202
|
+
<!-- Thread -->
|
|
203
|
+
<div v-if="!isLoading && thread" class="vk-stack">
|
|
204
|
+
<!-- Header -->
|
|
205
|
+
<div>
|
|
206
|
+
<div class="vk-ticket-header">
|
|
207
|
+
<h1 class="vk-ticket-title">{{ thread.title }}</h1>
|
|
208
|
+
<span v-if="thread.id" class="vk-badge id">
|
|
209
|
+
#{{ thread.id.slice(0, 8) }}
|
|
210
|
+
</span>
|
|
211
|
+
</div>
|
|
212
|
+
<div
|
|
213
|
+
v-if="
|
|
214
|
+
(thread.status?.label && thread.status.label.toLowerCase() !== 'open') ||
|
|
215
|
+
(thread.priority?.label && thread.priority.label.toLowerCase() !== 'low')
|
|
216
|
+
"
|
|
217
|
+
class="vk-ticket-badges"
|
|
218
|
+
>
|
|
219
|
+
<span
|
|
220
|
+
v-if="thread.status?.label && thread.status.label.toLowerCase() !== 'open'"
|
|
221
|
+
class="vk-badge status"
|
|
222
|
+
>
|
|
223
|
+
{{ thread.status.label }}
|
|
224
|
+
</span>
|
|
225
|
+
<span
|
|
226
|
+
v-if="thread.priority?.label && thread.priority.label.toLowerCase() !== 'low'"
|
|
227
|
+
class="vk-badge priority"
|
|
228
|
+
>
|
|
229
|
+
{{ thread.priority.label }}
|
|
230
|
+
</span>
|
|
231
|
+
</div>
|
|
232
|
+
</div>
|
|
233
|
+
|
|
234
|
+
<!-- Summary (ticket form answers) -->
|
|
235
|
+
<div v-if="summaryAnswers.length > 0" class="vk-section-card">
|
|
236
|
+
<div class="vk-section-header">
|
|
237
|
+
<span class="vk-section-header-icon">📝</span>
|
|
238
|
+
<h2>Summary</h2>
|
|
239
|
+
</div>
|
|
240
|
+
<div class="vk-section-body">
|
|
241
|
+
<div class="vk-summary-list">
|
|
242
|
+
<div
|
|
243
|
+
v-for="answer in summaryAnswers"
|
|
244
|
+
:key="answer.id"
|
|
245
|
+
class="vk-summary-item"
|
|
246
|
+
>
|
|
247
|
+
<p class="vk-summary-label">{{ answer.question_label || "Question" }}</p>
|
|
248
|
+
<div
|
|
249
|
+
v-if="answer.attachments && answer.attachments.length > 0"
|
|
250
|
+
class="vk-attachments"
|
|
251
|
+
>
|
|
252
|
+
<a
|
|
253
|
+
v-for="attachment in answer.attachments"
|
|
254
|
+
:key="attachment.id"
|
|
255
|
+
class="vk-attachment"
|
|
256
|
+
:href="attachment.url"
|
|
257
|
+
target="_blank"
|
|
258
|
+
rel="noopener noreferrer"
|
|
259
|
+
>
|
|
260
|
+
📎 <span>{{ attachment.original_filename }}</span>
|
|
261
|
+
</a>
|
|
262
|
+
</div>
|
|
263
|
+
<p v-else-if="isFileAnswer(answer.answer)" class="vk-summary-value muted">
|
|
264
|
+
File uploaded
|
|
265
|
+
</p>
|
|
266
|
+
<p v-else class="vk-summary-value">
|
|
267
|
+
{{ formatAnswerText(answer.answer) || "-" }}
|
|
268
|
+
</p>
|
|
269
|
+
</div>
|
|
270
|
+
</div>
|
|
271
|
+
</div>
|
|
272
|
+
</div>
|
|
273
|
+
|
|
274
|
+
<!-- Description (first reporter message) -->
|
|
275
|
+
<div v-if="firstReporterMessage" class="vk-section-card">
|
|
276
|
+
<div class="vk-section-header">
|
|
277
|
+
<span class="vk-section-header-icon">📄</span>
|
|
278
|
+
<h2>Description</h2>
|
|
279
|
+
</div>
|
|
280
|
+
<div class="vk-section-body">
|
|
281
|
+
<div
|
|
282
|
+
class="vk-description-content vk-message-html"
|
|
283
|
+
v-html="sanitizeHtml(firstReporterMessage.content)"
|
|
284
|
+
/>
|
|
285
|
+
<div
|
|
286
|
+
v-if="firstReporterMessage.attachments && firstReporterMessage.attachments.length > 0"
|
|
287
|
+
class="vk-attachments"
|
|
288
|
+
>
|
|
289
|
+
<a
|
|
290
|
+
v-for="att in firstReporterMessage.attachments"
|
|
291
|
+
:key="att.id"
|
|
292
|
+
:href="att.url"
|
|
293
|
+
target="_blank"
|
|
294
|
+
rel="noopener noreferrer"
|
|
295
|
+
class="vk-attachment"
|
|
296
|
+
>
|
|
297
|
+
📎 <span>{{ att.original_filename }}</span>
|
|
298
|
+
</a>
|
|
299
|
+
</div>
|
|
300
|
+
</div>
|
|
301
|
+
</div>
|
|
302
|
+
|
|
303
|
+
<!-- Comments card -->
|
|
304
|
+
<div class="vk-section-card">
|
|
305
|
+
<div class="vk-section-header">
|
|
306
|
+
<span class="vk-section-header-icon">💬</span>
|
|
307
|
+
<h2>Comments</h2>
|
|
308
|
+
</div>
|
|
309
|
+
|
|
310
|
+
<!-- Compose area -->
|
|
311
|
+
<div class="vk-compose">
|
|
312
|
+
<form class="vk-stack" @submit.prevent="submitReply">
|
|
313
|
+
<textarea
|
|
314
|
+
v-model="content"
|
|
315
|
+
class="vk-textarea"
|
|
316
|
+
placeholder="Write your reply..."
|
|
317
|
+
/>
|
|
318
|
+
|
|
319
|
+
<div class="vk-compose-row">
|
|
320
|
+
<!-- File input -->
|
|
321
|
+
<div class="vk-compose-files">
|
|
322
|
+
<label class="vk-browse-btn">
|
|
323
|
+
📎 Browse files
|
|
324
|
+
<input
|
|
325
|
+
type="file"
|
|
326
|
+
multiple
|
|
327
|
+
@change="onFileChange"
|
|
328
|
+
/>
|
|
329
|
+
</label>
|
|
330
|
+
<span
|
|
331
|
+
v-for="(file, i) in files"
|
|
332
|
+
:key="`${file.name}-${i}`"
|
|
333
|
+
class="vk-file-chip"
|
|
334
|
+
>
|
|
335
|
+
📎
|
|
336
|
+
<span class="vk-file-chip-name">{{ file.name }}</span>
|
|
337
|
+
<button
|
|
338
|
+
type="button"
|
|
339
|
+
:aria-label="`Remove ${file.name}`"
|
|
340
|
+
@click="removeFile(i)"
|
|
341
|
+
>
|
|
342
|
+
✕
|
|
343
|
+
</button>
|
|
344
|
+
</span>
|
|
345
|
+
</div>
|
|
346
|
+
|
|
347
|
+
<!-- Send -->
|
|
348
|
+
<button
|
|
349
|
+
class="vk-button primary"
|
|
350
|
+
:disabled="isSending"
|
|
351
|
+
type="submit"
|
|
352
|
+
>
|
|
353
|
+
<template v-if="isSending">
|
|
354
|
+
<span class="vk-spinner" />
|
|
355
|
+
Sending...
|
|
356
|
+
</template>
|
|
357
|
+
<template v-else>Send</template>
|
|
358
|
+
</button>
|
|
359
|
+
</div>
|
|
360
|
+
</form>
|
|
361
|
+
</div>
|
|
362
|
+
|
|
363
|
+
<!-- Message list -->
|
|
364
|
+
<div v-if="sortedMessages.length === 0" class="vk-empty-state">
|
|
365
|
+
<div class="vk-empty-icon">💬</div>
|
|
366
|
+
<p class="vk-empty-text">
|
|
367
|
+
No messages yet. Be the first to reply!
|
|
368
|
+
</p>
|
|
369
|
+
</div>
|
|
370
|
+
<div v-else class="vk-message-list">
|
|
371
|
+
<div v-for="(message, index) in sortedMessages" :key="message.id">
|
|
372
|
+
<hr v-if="index > 0" class="vk-message-divider" />
|
|
373
|
+
|
|
374
|
+
<!-- System message -->
|
|
375
|
+
<div v-if="message.author_type === 'system'" class="vk-message">
|
|
376
|
+
<div class="vk-avatar vk-avatar-system" aria-hidden="true">S</div>
|
|
377
|
+
<div class="vk-message-content">
|
|
378
|
+
<div class="vk-message-meta">
|
|
379
|
+
<span class="vk-author-name system">System</span>
|
|
380
|
+
<span class="vk-message-time">{{ formatDate(message.created_at) }}</span>
|
|
381
|
+
</div>
|
|
382
|
+
<p class="vk-system-text">{{ stripHtml(message.content) }}</p>
|
|
383
|
+
</div>
|
|
384
|
+
</div>
|
|
385
|
+
|
|
386
|
+
<!-- Reporter / Support message -->
|
|
387
|
+
<div v-else class="vk-message">
|
|
388
|
+
<div
|
|
389
|
+
:class="cn('vk-avatar', `vk-avatar-${message.author_type}`)"
|
|
390
|
+
aria-hidden="true"
|
|
391
|
+
>
|
|
392
|
+
{{ (AUTHOR_LABELS[message.author_type] || '?')[0] }}
|
|
393
|
+
</div>
|
|
394
|
+
<div class="vk-message-content">
|
|
395
|
+
<div class="vk-message-meta">
|
|
396
|
+
<span class="vk-author-name">
|
|
397
|
+
{{ AUTHOR_LABELS[message.author_type] || message.author_type }}
|
|
398
|
+
</span>
|
|
399
|
+
<span class="vk-message-time">{{ formatDate(message.created_at) }}</span>
|
|
400
|
+
</div>
|
|
401
|
+
<div
|
|
402
|
+
:class="cn('vk-message-bubble', message.author_type === 'user' && 'support')"
|
|
403
|
+
>
|
|
404
|
+
<div
|
|
405
|
+
class="vk-message-html"
|
|
406
|
+
v-html="sanitizeHtml(message.content)"
|
|
407
|
+
/>
|
|
408
|
+
</div>
|
|
409
|
+
<div
|
|
410
|
+
v-if="message.attachments && message.attachments.length > 0"
|
|
411
|
+
class="vk-attachments"
|
|
412
|
+
>
|
|
413
|
+
<a
|
|
414
|
+
v-for="attachment in message.attachments"
|
|
415
|
+
:key="attachment.id"
|
|
416
|
+
class="vk-attachment"
|
|
417
|
+
:href="attachment.url"
|
|
418
|
+
target="_blank"
|
|
419
|
+
rel="noopener noreferrer"
|
|
420
|
+
>
|
|
421
|
+
📎 <span>{{ attachment.original_filename }}</span>
|
|
422
|
+
</a>
|
|
423
|
+
</div>
|
|
424
|
+
</div>
|
|
425
|
+
</div>
|
|
426
|
+
</div>
|
|
427
|
+
</div>
|
|
428
|
+
</div>
|
|
429
|
+
</div>
|
|
430
|
+
</div>
|
|
431
|
+
</div>
|
|
432
|
+
</template>
|
|
433
|
+
|
|
434
|
+
<style src="~/assets/css/vicket.css"></style>
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { defineEventHandler, getQuery, readBody, readMultipartFormData, getMethod } from "h3";
|
|
2
|
+
|
|
3
|
+
export default defineEventHandler(async (event) => {
|
|
4
|
+
const apiUrl = (process.env.VICKET_API_URL || "").replace(/\/+$/, "");
|
|
5
|
+
const apiKey = process.env.VICKET_API_KEY || "";
|
|
6
|
+
|
|
7
|
+
if (!apiUrl || !apiKey) {
|
|
8
|
+
throw createError({
|
|
9
|
+
statusCode: 500,
|
|
10
|
+
statusMessage: "Missing VICKET_API_URL or VICKET_API_KEY environment variable.",
|
|
11
|
+
});
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const path = event.context.params?.path || "";
|
|
15
|
+
const method = getMethod(event);
|
|
16
|
+
const query = getQuery(event);
|
|
17
|
+
|
|
18
|
+
// Build target URL with query params
|
|
19
|
+
const queryString = new URLSearchParams(
|
|
20
|
+
Object.entries(query).reduce<Record<string, string>>((acc, [k, v]) => {
|
|
21
|
+
if (v !== undefined && v !== null) acc[k] = String(v);
|
|
22
|
+
return acc;
|
|
23
|
+
}, {}),
|
|
24
|
+
).toString();
|
|
25
|
+
const targetUrl = `${apiUrl}/public/support/${path}${queryString ? `?${queryString}` : ""}`;
|
|
26
|
+
|
|
27
|
+
// Determine content type from the incoming request
|
|
28
|
+
const contentType = event.node.req.headers["content-type"] || "";
|
|
29
|
+
const isMultipart = contentType.includes("multipart/form-data");
|
|
30
|
+
|
|
31
|
+
let fetchOptions: RequestInit;
|
|
32
|
+
|
|
33
|
+
if (method === "GET" || method === "HEAD") {
|
|
34
|
+
fetchOptions = {
|
|
35
|
+
method,
|
|
36
|
+
headers: {
|
|
37
|
+
"X-Api-Key": apiKey,
|
|
38
|
+
"Content-Type": "application/json",
|
|
39
|
+
},
|
|
40
|
+
};
|
|
41
|
+
} else if (isMultipart) {
|
|
42
|
+
// Read raw multipart form data and rebuild a FormData for the upstream request
|
|
43
|
+
const parts = await readMultipartFormData(event);
|
|
44
|
+
const formData = new FormData();
|
|
45
|
+
|
|
46
|
+
if (parts) {
|
|
47
|
+
for (const part of parts) {
|
|
48
|
+
if (part.filename) {
|
|
49
|
+
const blob = new Blob([part.data], { type: part.type || "application/octet-stream" });
|
|
50
|
+
formData.append(part.name || "file", blob, part.filename);
|
|
51
|
+
} else {
|
|
52
|
+
formData.append(part.name || "field", part.data.toString("utf-8"));
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
fetchOptions = {
|
|
58
|
+
method,
|
|
59
|
+
headers: {
|
|
60
|
+
"X-Api-Key": apiKey,
|
|
61
|
+
// Do NOT set Content-Type for FormData — fetch sets it with boundary automatically
|
|
62
|
+
},
|
|
63
|
+
body: formData,
|
|
64
|
+
};
|
|
65
|
+
} else {
|
|
66
|
+
// JSON body
|
|
67
|
+
const body = await readBody(event);
|
|
68
|
+
fetchOptions = {
|
|
69
|
+
method,
|
|
70
|
+
headers: {
|
|
71
|
+
"X-Api-Key": apiKey,
|
|
72
|
+
"Content-Type": "application/json",
|
|
73
|
+
},
|
|
74
|
+
body: body ? JSON.stringify(body) : undefined,
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const response = await fetch(targetUrl, fetchOptions);
|
|
79
|
+
const data = await response.json();
|
|
80
|
+
|
|
81
|
+
// Preserve the upstream status code
|
|
82
|
+
event.node.res.statusCode = response.status;
|
|
83
|
+
|
|
84
|
+
return data;
|
|
85
|
+
});
|