@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.
Files changed (71) hide show
  1. package/bin/create-vicket-support.js +429 -389
  2. package/package.json +1 -1
  3. package/templates/next/src/app/api/vicket/[...path]/route.ts +2 -55
  4. package/templates/next/src/app/components/vicket/ReplyForm.tsx +154 -0
  5. package/templates/next/src/app/components/vicket/SupportContent.tsx +298 -0
  6. package/templates/next/src/app/components/vicket/TicketDialog.tsx +3 -3
  7. package/templates/next/src/app/support/page.tsx +27 -353
  8. package/templates/next/src/app/ticket/page.tsx +110 -325
  9. package/templates/next/src/app/vicket.css +1325 -1325
  10. package/templates/nuxt/app/assets/css/vicket.css +1325 -1325
  11. package/templates/nuxt/app/components/VicketReplyForm.vue +154 -0
  12. package/templates/nuxt/app/components/VicketSupportContent.vue +255 -0
  13. package/templates/nuxt/app/components/VicketTicketDialog.vue +2 -2
  14. package/templates/nuxt/app/pages/support.vue +7 -293
  15. package/templates/nuxt/app/pages/ticket.vue +36 -178
  16. package/templates/nuxt/server/api/vicket/[...path].ts +2 -85
  17. package/templates/sveltekit/src/lib/vicket/ReplyForm.svelte +134 -0
  18. package/templates/sveltekit/src/lib/vicket/SupportContent.svelte +263 -0
  19. package/templates/sveltekit/src/lib/vicket/TicketDialog.svelte +457 -459
  20. package/templates/sveltekit/src/lib/vicket.css +1325 -1325
  21. package/templates/sveltekit/src/routes/api/vicket/[...path]/+server.ts +2 -76
  22. package/templates/sveltekit/src/routes/support/+page.server.ts +13 -0
  23. package/templates/sveltekit/src/routes/support/+page.svelte +3 -312
  24. package/templates/sveltekit/src/routes/ticket/+page.server.ts +19 -0
  25. package/templates/sveltekit/src/routes/ticket/+page.svelte +13 -188
  26. package/templates-tailwind/next/src/app/api/vicket/[...path]/route.ts +6 -0
  27. package/templates-tailwind/next/src/app/support/page.tsx +33 -3
  28. package/templates-tailwind/next/src/app/ticket/page.tsx +249 -6
  29. package/templates-tailwind/next/src/components/vicket/reply-form.tsx +113 -0
  30. package/templates-tailwind/next/src/components/vicket/support-content.tsx +265 -0
  31. package/templates-tailwind/next/src/components/vicket/ticket-dialog.tsx +2 -2
  32. package/templates-tailwind/nuxt/app/components/VicketReplyForm.vue +169 -0
  33. package/templates-tailwind/nuxt/app/components/{VicketSupportPage.vue → VicketSupportContent.vue} +275 -317
  34. package/templates-tailwind/nuxt/app/components/VicketTicketDialog.vue +3 -0
  35. package/templates-tailwind/nuxt/app/pages/support.vue +10 -1
  36. package/templates-tailwind/nuxt/app/pages/ticket.vue +298 -1
  37. package/templates-tailwind/nuxt/server/api/vicket/[...path].ts +2 -0
  38. package/templates-tailwind/sveltekit/src/lib/vicket/ReplyForm.svelte +127 -0
  39. package/templates-tailwind/sveltekit/src/lib/vicket/{SupportPage.svelte → SupportContent.svelte} +9 -71
  40. package/templates-tailwind/sveltekit/src/lib/vicket/TicketDialog.svelte +405 -406
  41. package/templates-tailwind/sveltekit/src/routes/api/vicket/[...path]/+server.ts +3 -0
  42. package/templates-tailwind/sveltekit/src/routes/support/+page.server.ts +13 -0
  43. package/templates-tailwind/sveltekit/src/routes/support/+page.svelte +4 -2
  44. package/templates-tailwind/sveltekit/src/routes/ticket/+page.server.ts +19 -0
  45. package/templates-tailwind/sveltekit/src/routes/ticket/+page.svelte +292 -2
  46. package/templates/next/src/app/utils/vicket/api.ts +0 -149
  47. package/templates/next/src/app/utils/vicket/types.ts +0 -85
  48. package/templates/next/src/app/utils/vicket/utils.ts +0 -49
  49. package/templates/nuxt/app/composables/useVicket.ts +0 -274
  50. package/templates/sveltekit/src/lib/vicket/api.ts +0 -162
  51. package/templates/sveltekit/src/lib/vicket/types.ts +0 -87
  52. package/templates/sveltekit/src/lib/vicket/utils.ts +0 -55
  53. package/templates-tailwind/next/src/app/api/vicket/init/route.ts +0 -24
  54. package/templates-tailwind/next/src/app/api/vicket/messages/route.ts +0 -36
  55. package/templates-tailwind/next/src/app/api/vicket/thread/route.ts +0 -27
  56. package/templates-tailwind/next/src/app/api/vicket/tickets/route.ts +0 -37
  57. package/templates-tailwind/next/src/components/vicket/support-page.tsx +0 -359
  58. package/templates-tailwind/next/src/components/vicket/ticket-page.tsx +0 -425
  59. package/templates-tailwind/next/src/lib/vicket.ts +0 -257
  60. package/templates-tailwind/nuxt/app/components/VicketTicketPage.vue +0 -449
  61. package/templates-tailwind/nuxt/app/composables/use-vicket.ts +0 -249
  62. package/templates-tailwind/nuxt/server/api/vicket/init.get.ts +0 -22
  63. package/templates-tailwind/nuxt/server/api/vicket/messages.post.ts +0 -56
  64. package/templates-tailwind/nuxt/server/api/vicket/thread.get.ts +0 -26
  65. package/templates-tailwind/nuxt/server/api/vicket/tickets.post.ts +0 -53
  66. package/templates-tailwind/sveltekit/src/lib/vicket/TicketPage.svelte +0 -465
  67. package/templates-tailwind/sveltekit/src/lib/vicket/index.ts +0 -257
  68. package/templates-tailwind/sveltekit/src/routes/api/vicket/init/+server.ts +0 -22
  69. package/templates-tailwind/sveltekit/src/routes/api/vicket/messages/+server.ts +0 -40
  70. package/templates-tailwind/sveltekit/src/routes/api/vicket/thread/+server.ts +0 -25
  71. package/templates-tailwind/sveltekit/src/routes/api/vicket/tickets/+server.ts +0 -37
@@ -1,303 +1,17 @@
1
1
  <script setup lang="ts">
2
- import type { Template, Article, Faq } from "~/composables/useVicket";
3
- import { cn, stripHtml, sanitizeHtml, fetchSupportInit } from "~/composables/useVicket";
2
+ import type { SupportInitResponse } from "vicket";
4
3
 
5
4
  /* ---------------------------------------------- */
6
- /* Reactive state */
5
+ /* SSR data fetching */
7
6
  /* ---------------------------------------------- */
8
- const templates = ref<Template[]>([]);
9
- const articles = ref<Article[]>([]);
10
- const faqs = ref<Faq[]>([]);
11
- const websiteName = ref("Support");
12
- const isLoading = ref(true);
13
- const error = ref("");
14
- const searchQuery = ref("");
15
- const dialogOpen = ref(false);
16
- const selectedArticle = ref<Article | null>(null);
17
-
18
- /* FAQ open state */
19
- const openFaqIds = ref<Set<string>>(new Set());
20
-
21
- /* ---------------------------------------------- */
22
- /* Computed */
23
- /* ---------------------------------------------- */
24
- const filteredArticles = computed(() => {
25
- if (!searchQuery.value.trim()) return articles.value;
26
- const q = searchQuery.value.toLowerCase();
27
- return articles.value.filter(
28
- (a) =>
29
- a.title.toLowerCase().includes(q) ||
30
- stripHtml(a.content).toLowerCase().includes(q),
31
- );
32
- });
33
-
34
- const filteredFaqs = computed(() => {
35
- if (!searchQuery.value.trim()) return faqs.value;
36
- const q = searchQuery.value.toLowerCase();
37
- return faqs.value.filter(
38
- (f) =>
39
- f.question.toLowerCase().includes(q) ||
40
- f.answer.toLowerCase().includes(q),
41
- );
42
- });
43
-
44
- const hasContent = computed(() => articles.value.length > 0 || faqs.value.length > 0);
45
- const hasResults = computed(() => filteredArticles.value.length > 0 || filteredFaqs.value.length > 0);
46
-
47
- /* ---------------------------------------------- */
48
- /* FAQ accordion */
49
- /* ---------------------------------------------- */
50
- const toggleFaq = (id: string) => {
51
- const next = new Set(openFaqIds.value);
52
- if (next.has(id)) {
53
- next.delete(id);
54
- } else {
55
- next.add(id);
56
- }
57
- openFaqIds.value = next;
58
- };
59
-
60
- const isFaqOpen = (id: string) => openFaqIds.value.has(id);
61
-
62
- /* ---------------------------------------------- */
63
- /* Dialog helpers */
64
- /* ---------------------------------------------- */
65
- const openDialog = () => {
66
- dialogOpen.value = true;
67
- };
68
-
69
- /* ---------------------------------------------- */
70
- /* Data loading */
71
- /* ---------------------------------------------- */
72
- onMounted(async () => {
73
- isLoading.value = true;
74
- error.value = "";
75
- try {
76
- const data = await fetchSupportInit();
77
- templates.value = data.templates || [];
78
- articles.value = data.articles || [];
79
- faqs.value = data.faqs || [];
80
- websiteName.value = data.website?.name || "Support";
81
- } catch (loadError) {
82
- error.value = loadError instanceof Error ? loadError.message : "Unexpected error.";
83
- } finally {
84
- isLoading.value = false;
85
- }
86
- });
7
+ const { data: initData, error: fetchError } = await useFetch<SupportInitResponse>('/api/vicket/init');
87
8
  </script>
88
9
 
89
10
  <template>
90
- <!-- Skeleton loading state -->
91
- <div v-if="isLoading" class="vk-shell">
92
- <div class="vk-page vk-animate-in">
93
- <div class="vk-hero-row">
94
- <div>
95
- <div class="vk-skeleton" aria-hidden="true">
96
- &nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;
97
- </div>
98
- <div class="vk-skeleton" aria-hidden="true">
99
- &nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;
100
- </div>
101
- </div>
102
- <div class="vk-skeleton" aria-hidden="true">
103
- &nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;
104
- </div>
105
- </div>
106
- <div class="vk-search-wrap">
107
- <div class="vk-skeleton" aria-hidden="true">&nbsp;</div>
108
- </div>
109
- <div class="vk-content-grid">
110
- <div>
111
- <div class="vk-skeleton" aria-hidden="true">&nbsp;</div>
112
- <div class="vk-skeleton" aria-hidden="true">&nbsp;</div>
113
- <div class="vk-skeleton" aria-hidden="true">&nbsp;</div>
114
- </div>
115
- <div>
116
- <div class="vk-skeleton" aria-hidden="true">&nbsp;</div>
117
- <div class="vk-skeleton" aria-hidden="true">&nbsp;</div>
118
- <div class="vk-skeleton" aria-hidden="true">&nbsp;</div>
119
- </div>
120
- </div>
121
- </div>
122
- </div>
123
-
124
- <!-- Article viewer -->
125
- <div v-else-if="selectedArticle" class="vk-shell">
126
- <div class="vk-page vk-animate-in">
127
- <!-- Hero stays visible -->
128
- <div class="vk-hero-row">
129
- <div>
130
- <h1 class="vk-hero-title">{{ websiteName }}</h1>
131
- <p class="vk-hero-subtitle">How can we help you today?</p>
132
- </div>
133
- <button
134
- v-if="templates.length > 0"
135
- type="button"
136
- class="vk-button primary pill"
137
- @click="openDialog"
138
- >
139
- &#128172; Contact Support
140
- </button>
141
- </div>
142
-
143
- <!-- Article content -->
144
- <div class="vk-article-viewer">
145
- <button
146
- type="button"
147
- class="vk-back-button"
148
- @click="selectedArticle = null"
149
- >
150
- &#8592; Back to articles
151
- </button>
152
-
153
- <div class="vk-article-viewer-card">
154
- <h2 class="vk-article-viewer-title">{{ selectedArticle.title }}</h2>
155
- <div
156
- class="vk-article-viewer-content vk-message-html"
157
- v-html="sanitizeHtml(selectedArticle.content)"
158
- />
159
- </div>
160
- </div>
161
- </div>
162
-
163
- <VicketTicketDialog
164
- v-model="dialogOpen"
165
- :templates="templates"
166
- />
167
- </div>
168
-
169
- <!-- Home view -->
170
- <div v-else class="vk-shell">
171
- <div class="vk-page vk-animate-in">
172
- <!-- Row 1 - Hero -->
173
- <div class="vk-hero-row">
174
- <div>
175
- <h1 class="vk-hero-title">{{ websiteName }}</h1>
176
- <p class="vk-hero-subtitle">How can we help you today?</p>
177
- </div>
178
- <div v-if="templates.length > 0" class="vk-hero-cta">
179
- <span class="vk-hero-cta-hint">Can't find what you're looking for?</span>
180
- <button
181
- type="button"
182
- class="vk-button primary pill"
183
- @click="openDialog"
184
- >
185
- &#128172; Contact Support
186
- </button>
187
- </div>
188
- </div>
189
-
190
- <!-- Alerts -->
191
- <div
192
- v-if="error"
193
- :class="cn('vk-alert vk-slide-up', 'error')"
194
- role="alert"
195
- >
196
- <span>&#9888;</span>
197
- <span>{{ error }}</span>
198
- <button
199
- type="button"
200
- class="vk-alert-dismiss"
201
- aria-label="Dismiss"
202
- @click="error = ''"
203
- >
204
- &#10005;
205
- </button>
206
- </div>
207
-
208
- <!-- Row 2 - Search bar -->
209
- <div v-if="hasContent" class="vk-search-wrap">
210
- <span class="vk-search-icon">&#128269;</span>
211
- <input
212
- type="text"
213
- class="vk-search-input"
214
- placeholder="Search articles and FAQs..."
215
- v-model="searchQuery"
216
- />
217
- </div>
218
-
219
- <!-- Row 3 - Split content -->
220
- <div v-if="hasResults" class="vk-content-grid">
221
- <!-- Left: Articles -->
222
- <div v-if="filteredArticles.length > 0">
223
- <div class="vk-section-title-row">
224
- <span class="vk-section-title-icon">&#128196;</span>
225
- <h2 class="vk-section-title">Popular Articles</h2>
226
- </div>
227
- <div class="vk-article-list">
228
- <button
229
- v-for="article in filteredArticles"
230
- :key="article.id"
231
- type="button"
232
- class="vk-article-card"
233
- @click="selectedArticle = article"
234
- >
235
- <div class="vk-article-icon" aria-hidden="true">&#128196;</div>
236
- <div>
237
- <h3 class="vk-article-title">{{ article.title }}</h3>
238
- <p v-if="article.content" class="vk-article-preview">
239
- {{ stripHtml(article.content).substring(0, 150) }}
240
- </p>
241
- </div>
242
- </button>
243
- </div>
244
- </div>
245
-
246
- <!-- Right: FAQs -->
247
- <div v-if="filteredFaqs.length > 0">
248
- <div class="vk-section-title-row">
249
- <span class="vk-section-title-icon">&#10068;</span>
250
- <h2 class="vk-section-title">Frequently Asked Questions</h2>
251
- </div>
252
- <div class="vk-faq-list">
253
- <div
254
- v-for="faq in filteredFaqs"
255
- :key="faq.id"
256
- class="vk-faq-item"
257
- >
258
- <button
259
- type="button"
260
- class="vk-faq-question"
261
- :aria-expanded="isFaqOpen(faq.id)"
262
- @click="toggleFaq(faq.id)"
263
- >
264
- {{ faq.question }}
265
- <span :class="cn('vk-faq-chevron', isFaqOpen(faq.id) && 'open')">&#9660;</span>
266
- </button>
267
- <div :class="cn('vk-faq-body', isFaqOpen(faq.id) && 'open')">
268
- <div>
269
- <div class="vk-faq-answer">{{ faq.answer }}</div>
270
- </div>
271
- </div>
272
- </div>
273
- </div>
274
- </div>
275
- </div>
276
-
277
- <!-- No search results -->
278
- <div v-if="searchQuery.trim() && !hasResults" class="vk-no-results">
279
- No results found for &ldquo;{{ searchQuery }}&rdquo;
280
- </div>
281
-
282
- <!-- Empty state - no articles and no FAQs at all -->
283
- <div v-if="!hasContent && !error" class="vk-cta-card">
284
- <div class="vk-cta-icon">&#128172;</div>
285
- <p class="vk-cta-text">Need help? Our team is here for you.</p>
286
- <button
287
- type="button"
288
- class="vk-button primary pill"
289
- @click="openDialog"
290
- >
291
- Contact Support
292
- </button>
293
- </div>
294
- </div>
295
-
296
- <VicketTicketDialog
297
- v-model="dialogOpen"
298
- :templates="templates"
299
- />
300
- </div>
11
+ <VicketSupportContent
12
+ :init-data="initData?.data"
13
+ :init-error="fetchError ? 'Failed to load support data.' : ''"
14
+ />
301
15
  </template>
302
16
 
303
17
  <style src="~/assets/css/vicket.css"></style>
@@ -1,5 +1,5 @@
1
1
  <script setup lang="ts">
2
- import type { TicketThread as TicketThreadType } from "~/composables/useVicket";
2
+ import type { TicketThread as TicketThreadType } from "vicket";
3
3
  import {
4
4
  cn,
5
5
  stripHtml,
@@ -8,24 +8,41 @@ import {
8
8
  isFileAnswer,
9
9
  formatAnswerText,
10
10
  AUTHOR_LABELS,
11
- fetchTicketThread,
12
- sendReply,
13
- } from "~/composables/useVicket";
11
+ } from "vicket";
14
12
 
15
13
  /* ---------------------------------------------- */
16
14
  /* Reactive state */
17
15
  /* ---------------------------------------------- */
18
16
  const route = useRoute();
19
17
  const token = computed(() => String(route.query.token || ""));
20
- const hasToken = computed(() => token.value.trim().length > 0);
21
18
 
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("");
19
+ /* ---------------------------------------------- */
20
+ /* SSR data fetching */
21
+ /* ---------------------------------------------- */
22
+ const { data: threadRaw, error: fetchError, refresh } = await useFetch<{
23
+ success?: boolean;
24
+ error?: string;
25
+ error_code?: string;
26
+ data?: TicketThreadType;
27
+ }>(`/api/vicket/ticket`, {
28
+ query: { token: token.value },
29
+ });
30
+
31
+ const thread = computed<TicketThreadType | null>(() => {
32
+ const raw = threadRaw.value;
33
+ if (!raw?.success || !raw?.data) return null;
34
+ return raw.data;
35
+ });
36
+
37
+ const error = ref(
38
+ fetchError.value
39
+ ? "Failed to load ticket."
40
+ : threadRaw.value && !threadRaw.value.success
41
+ ? (threadRaw.value.error_code === "ticket-link-expired"
42
+ ? "This link has expired. A new secure link has been sent to your email."
43
+ : threadRaw.value.error || "Failed to load ticket.")
44
+ : "",
45
+ );
29
46
 
30
47
  /* ---------------------------------------------- */
31
48
  /* Computed */
@@ -35,7 +52,6 @@ const firstReporterMessage = computed(() => {
35
52
  const sorted = [...thread.value.messages].sort(
36
53
  (a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime(),
37
54
  );
38
- // Only treat as description if the very first message is from the reporter
39
55
  return sorted[0].author_type === "reporter" ? sorted[0] : null;
40
56
  });
41
57
 
@@ -56,76 +72,11 @@ const summaryAnswers = computed(() => {
56
72
  });
57
73
 
58
74
  /* ---------------------------------------------- */
59
- /* Helpers */
75
+ /* Reply callback */
60
76
  /* ---------------------------------------------- */
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
- }
77
+ const onReplied = async () => {
78
+ await refresh();
92
79
  };
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
80
  </script>
130
81
 
131
82
  <template>
@@ -138,7 +89,7 @@ watch(
138
89
  </NuxtLink>
139
90
  </div>
140
91
 
141
- <!-- Alerts -->
92
+ <!-- Error alert -->
142
93
  <div
143
94
  v-if="error"
144
95
  :class="cn('vk-alert vk-slide-up', 'error')"
@@ -155,52 +106,9 @@ watch(
155
106
  &#10005;
156
107
  </button>
157
108
  </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
109
 
202
110
  <!-- Thread -->
203
- <div v-if="!isLoading && thread" class="vk-stack">
111
+ <div v-if="thread" class="vk-stack">
204
112
  <!-- Header -->
205
113
  <div>
206
114
  <div class="vk-ticket-header">
@@ -307,58 +215,8 @@ watch(
307
215
  <h2>Comments</h2>
308
216
  </div>
309
217
 
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>
218
+ <!-- Reply form (client interactive) -->
219
+ <VicketReplyForm :token="token" @replied="onReplied" />
362
220
 
363
221
  <!-- Message list -->
364
222
  <div v-if="sortedMessages.length === 0" class="vk-empty-state">
@@ -1,85 +1,2 @@
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
- });
1
+ import { createVicketProxy } from "vicket/nuxt";
2
+ export default createVicketProxy();