@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,303 +1,17 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
|
-
import type {
|
|
3
|
-
import { cn, stripHtml, sanitizeHtml, fetchSupportInit } from "~/composables/useVicket";
|
|
2
|
+
import type { SupportInitResponse } from "vicket";
|
|
4
3
|
|
|
5
4
|
/* ---------------------------------------------- */
|
|
6
|
-
/*
|
|
5
|
+
/* SSR data fetching */
|
|
7
6
|
/* ---------------------------------------------- */
|
|
8
|
-
const
|
|
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
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
<div>
|
|
95
|
-
<div class="vk-skeleton" aria-hidden="true">
|
|
96
|
-
|
|
97
|
-
</div>
|
|
98
|
-
<div class="vk-skeleton" aria-hidden="true">
|
|
99
|
-
|
|
100
|
-
</div>
|
|
101
|
-
</div>
|
|
102
|
-
<div class="vk-skeleton" aria-hidden="true">
|
|
103
|
-
|
|
104
|
-
</div>
|
|
105
|
-
</div>
|
|
106
|
-
<div class="vk-search-wrap">
|
|
107
|
-
<div class="vk-skeleton" aria-hidden="true"> </div>
|
|
108
|
-
</div>
|
|
109
|
-
<div class="vk-content-grid">
|
|
110
|
-
<div>
|
|
111
|
-
<div class="vk-skeleton" aria-hidden="true"> </div>
|
|
112
|
-
<div class="vk-skeleton" aria-hidden="true"> </div>
|
|
113
|
-
<div class="vk-skeleton" aria-hidden="true"> </div>
|
|
114
|
-
</div>
|
|
115
|
-
<div>
|
|
116
|
-
<div class="vk-skeleton" aria-hidden="true"> </div>
|
|
117
|
-
<div class="vk-skeleton" aria-hidden="true"> </div>
|
|
118
|
-
<div class="vk-skeleton" aria-hidden="true"> </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
|
-
💬 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
|
-
← 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
|
-
💬 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>⚠</span>
|
|
197
|
-
<span>{{ error }}</span>
|
|
198
|
-
<button
|
|
199
|
-
type="button"
|
|
200
|
-
class="vk-alert-dismiss"
|
|
201
|
-
aria-label="Dismiss"
|
|
202
|
-
@click="error = ''"
|
|
203
|
-
>
|
|
204
|
-
✕
|
|
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">🔍</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">📄</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">📄</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">❔</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')">▼</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 “{{ searchQuery }}”
|
|
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">💬</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 "
|
|
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
|
-
|
|
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
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
const
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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
|
-
/*
|
|
75
|
+
/* Reply callback */
|
|
60
76
|
/* ---------------------------------------------- */
|
|
61
|
-
const
|
|
62
|
-
|
|
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
|
-
<!--
|
|
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
|
✕
|
|
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>✓</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
109
|
|
|
202
110
|
<!-- Thread -->
|
|
203
|
-
<div v-if="
|
|
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
|
-
<!--
|
|
311
|
-
<
|
|
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>
|
|
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 {
|
|
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();
|