@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.
Files changed (55) hide show
  1. package/README.md +52 -0
  2. package/bin/create-vicket-support.js +389 -0
  3. package/package.json +18 -0
  4. package/templates/next/src/app/api/vicket/[...path]/route.ts +59 -0
  5. package/templates/next/src/app/components/vicket/TicketDialog.tsx +514 -0
  6. package/templates/next/src/app/support/page.tsx +358 -0
  7. package/templates/next/src/app/ticket/page.tsx +483 -0
  8. package/templates/next/src/app/utils/vicket/api.ts +149 -0
  9. package/templates/next/src/app/utils/vicket/types.ts +85 -0
  10. package/templates/next/src/app/utils/vicket/utils.ts +49 -0
  11. package/templates/next/src/app/vicket.css +1325 -0
  12. package/templates/nuxt/app/assets/css/vicket.css +1325 -0
  13. package/templates/nuxt/app/components/VicketTicketDialog.vue +499 -0
  14. package/templates/nuxt/app/composables/useVicket.ts +274 -0
  15. package/templates/nuxt/app/pages/support.vue +303 -0
  16. package/templates/nuxt/app/pages/ticket.vue +434 -0
  17. package/templates/nuxt/server/api/vicket/[...path].ts +85 -0
  18. package/templates/sveltekit/src/lib/vicket/TicketDialog.svelte +459 -0
  19. package/templates/sveltekit/src/lib/vicket/api.ts +162 -0
  20. package/templates/sveltekit/src/lib/vicket/types.ts +87 -0
  21. package/templates/sveltekit/src/lib/vicket/utils.ts +55 -0
  22. package/templates/sveltekit/src/lib/vicket.css +1325 -0
  23. package/templates/sveltekit/src/routes/api/vicket/[...path]/+server.ts +77 -0
  24. package/templates/sveltekit/src/routes/support/+page.svelte +316 -0
  25. package/templates/sveltekit/src/routes/ticket/+page.svelte +418 -0
  26. package/templates-tailwind/next/src/app/api/vicket/init/route.ts +24 -0
  27. package/templates-tailwind/next/src/app/api/vicket/messages/route.ts +36 -0
  28. package/templates-tailwind/next/src/app/api/vicket/thread/route.ts +27 -0
  29. package/templates-tailwind/next/src/app/api/vicket/tickets/route.ts +37 -0
  30. package/templates-tailwind/next/src/app/support/page.tsx +5 -0
  31. package/templates-tailwind/next/src/app/ticket/page.tsx +10 -0
  32. package/templates-tailwind/next/src/components/vicket/support-page.tsx +359 -0
  33. package/templates-tailwind/next/src/components/vicket/ticket-dialog.tsx +306 -0
  34. package/templates-tailwind/next/src/components/vicket/ticket-page.tsx +425 -0
  35. package/templates-tailwind/next/src/lib/vicket.ts +257 -0
  36. package/templates-tailwind/nuxt/app/components/VicketSupportPage.vue +317 -0
  37. package/templates-tailwind/nuxt/app/components/VicketTicketDialog.vue +444 -0
  38. package/templates-tailwind/nuxt/app/components/VicketTicketPage.vue +449 -0
  39. package/templates-tailwind/nuxt/app/composables/use-vicket.ts +249 -0
  40. package/templates-tailwind/nuxt/app/pages/support.vue +3 -0
  41. package/templates-tailwind/nuxt/app/pages/ticket.vue +3 -0
  42. package/templates-tailwind/nuxt/server/api/vicket/init.get.ts +22 -0
  43. package/templates-tailwind/nuxt/server/api/vicket/messages.post.ts +56 -0
  44. package/templates-tailwind/nuxt/server/api/vicket/thread.get.ts +26 -0
  45. package/templates-tailwind/nuxt/server/api/vicket/tickets.post.ts +53 -0
  46. package/templates-tailwind/sveltekit/src/lib/vicket/SupportPage.svelte +395 -0
  47. package/templates-tailwind/sveltekit/src/lib/vicket/TicketDialog.svelte +406 -0
  48. package/templates-tailwind/sveltekit/src/lib/vicket/TicketPage.svelte +465 -0
  49. package/templates-tailwind/sveltekit/src/lib/vicket/index.ts +257 -0
  50. package/templates-tailwind/sveltekit/src/routes/api/vicket/init/+server.ts +22 -0
  51. package/templates-tailwind/sveltekit/src/routes/api/vicket/messages/+server.ts +40 -0
  52. package/templates-tailwind/sveltekit/src/routes/api/vicket/thread/+server.ts +25 -0
  53. package/templates-tailwind/sveltekit/src/routes/api/vicket/tickets/+server.ts +37 -0
  54. package/templates-tailwind/sveltekit/src/routes/support/+page.svelte +5 -0
  55. 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
+ &#8592; 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>&#9888;</span>
148
+ <span>{{ error }}</span>
149
+ <button
150
+ type="button"
151
+ class="vk-alert-dismiss"
152
+ aria-label="Dismiss"
153
+ @click="error = ''"
154
+ >
155
+ &#10005;
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>&#10003;</span>
164
+ <span>{{ success }}</span>
165
+ <button
166
+ type="button"
167
+ class="vk-alert-dismiss"
168
+ aria-label="Dismiss"
169
+ @click="success = ''"
170
+ >
171
+ &#10005;
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
+ &nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;
181
+ </div>
182
+ <div class="vk-compose-files">
183
+ <span class="vk-skeleton" aria-hidden="true">&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;</span>
184
+ <span class="vk-skeleton" aria-hidden="true">&nbsp;&nbsp;&nbsp;&nbsp;</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">&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;</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">&nbsp;</div>
194
+ <div class="vk-message-content">
195
+ <div class="vk-skeleton" aria-hidden="true">&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;</div>
196
+ <div class="vk-skeleton" aria-hidden="true">&nbsp;</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">&#128221;</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
+ &#128206; <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">&#128196;</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
+ &#128206; <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">&#128172;</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
+ &#128206; 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
+ &#128206;
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
+ &#10005;
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">&#128172;</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
+ &#128206; <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
+ });