@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,274 @@
1
+ /* ---------------------------------------------- */
2
+ /* Types */
3
+ /* ---------------------------------------------- */
4
+ export type TemplateOption = {
5
+ id: string;
6
+ label: string;
7
+ value: string;
8
+ };
9
+
10
+ export type TemplateQuestion = {
11
+ id: string;
12
+ label: string;
13
+ type: "TEXT" | "TEXTAREA" | "SELECT" | "CHECKBOX" | "DATE" | "FILE";
14
+ required: boolean;
15
+ order: number;
16
+ options?: TemplateOption[];
17
+ };
18
+
19
+ export type Template = {
20
+ id: string;
21
+ name: string;
22
+ description: string;
23
+ questions: TemplateQuestion[];
24
+ };
25
+
26
+ export type Article = {
27
+ id: string;
28
+ title: string;
29
+ slug: string;
30
+ content: string;
31
+ };
32
+
33
+ export type Faq = {
34
+ id: string;
35
+ question: string;
36
+ answer: string;
37
+ };
38
+
39
+ export type SupportInitResponse = {
40
+ success: boolean;
41
+ data?: {
42
+ website?: { name?: string };
43
+ templates: Template[];
44
+ articles?: Article[];
45
+ faqs?: Faq[];
46
+ };
47
+ error?: string;
48
+ };
49
+
50
+ export type FormValues = {
51
+ email: string;
52
+ title: string;
53
+ answers: Record<string, unknown>;
54
+ };
55
+
56
+ export type Attachment = {
57
+ id: string;
58
+ original_filename: string;
59
+ url: string;
60
+ };
61
+
62
+ export type Message = {
63
+ id: string;
64
+ content: string;
65
+ author_type: "reporter" | "user" | "system";
66
+ created_at: string;
67
+ attachments?: Attachment[];
68
+ };
69
+
70
+ export type TicketAnswer = {
71
+ id: string;
72
+ question_label: string;
73
+ answer: string;
74
+ attachments?: Attachment[];
75
+ };
76
+
77
+ export type TicketThread = {
78
+ id: string;
79
+ title: string;
80
+ status?: { label: string };
81
+ priority?: { label: string };
82
+ messages: Message[];
83
+ answers?: TicketAnswer[];
84
+ };
85
+
86
+ /* ---------------------------------------------- */
87
+ /* Constants */
88
+ /* ---------------------------------------------- */
89
+ export const PROXY_BASE = "/api/vicket";
90
+
91
+ export const AUTHOR_LABELS: Record<string, string> = {
92
+ reporter: "You",
93
+ user: "Support",
94
+ system: "System",
95
+ };
96
+
97
+ export const initialFormValues: FormValues = { email: "", title: "", answers: {} };
98
+
99
+ /* ---------------------------------------------- */
100
+ /* Utilities */
101
+ /* ---------------------------------------------- */
102
+ export function cn(...classes: (string | false | null | undefined)[]): string {
103
+ return classes.filter(Boolean).join(" ");
104
+ }
105
+
106
+ export function stripHtml(html: string): string {
107
+ return html.replace(/<[^>]*>/g, "");
108
+ }
109
+
110
+ export function sanitizeHtml(html: string): string {
111
+ return html
112
+ .replace(/<script[\s\S]*?<\/script>/gi, "")
113
+ .replace(/\son\w+="[^"]*"/gi, "")
114
+ .replace(/\son\w+='[^']*'/gi, "")
115
+ .replace(/href\s*=\s*"javascript:[^"]*"/gi, 'href="#"')
116
+ .replace(/href\s*=\s*'javascript:[^']*'/gi, "href='#'");
117
+ }
118
+
119
+ export function formatDate(iso: string): string {
120
+ try {
121
+ return new Intl.DateTimeFormat("en", {
122
+ month: "short",
123
+ day: "numeric",
124
+ hour: "numeric",
125
+ minute: "2-digit",
126
+ }).format(new Date(iso));
127
+ } catch {
128
+ return iso;
129
+ }
130
+ }
131
+
132
+ export function isFileAnswer(answer: string): boolean {
133
+ return answer?.includes("__isFile:true") || answer?.includes("map[__isFile");
134
+ }
135
+
136
+ export function formatAnswerText(value: string): string {
137
+ if (!value) return "";
138
+
139
+ const trimmed = value.trim();
140
+ if (trimmed.startsWith("[") && trimmed.endsWith("]")) {
141
+ const rawItems = trimmed.slice(1, -1).trim();
142
+ return rawItems.length > 0 ? rawItems.split(/\s+/).join(", ") : "";
143
+ }
144
+
145
+ return value;
146
+ }
147
+
148
+ /* ---------------------------------------------- */
149
+ /* API functions */
150
+ /* ---------------------------------------------- */
151
+ export async function fetchSupportInit(): Promise<NonNullable<SupportInitResponse["data"]>> {
152
+ const payload = await $fetch<SupportInitResponse>(`${PROXY_BASE}/init`, {
153
+ method: "GET",
154
+ });
155
+
156
+ if (!payload?.success || !payload?.data) {
157
+ throw new Error(payload?.error || "Failed to load support data.");
158
+ }
159
+
160
+ return payload.data;
161
+ }
162
+
163
+ export async function createTicket(body: {
164
+ email: string;
165
+ title: string;
166
+ templateId: string;
167
+ answers: Record<string, unknown>;
168
+ hasFiles: boolean;
169
+ fileQuestionIds: string[];
170
+ }): Promise<{ emailLimitReached?: boolean; warning?: string }> {
171
+ const payload = {
172
+ email: body.email,
173
+ title: body.title,
174
+ templateId: body.templateId,
175
+ answers: { ...body.answers },
176
+ };
177
+
178
+ let response: Response;
179
+ if (body.hasFiles) {
180
+ const formData = new FormData();
181
+ const normalizedAnswers: Record<string, unknown> = {};
182
+ for (const [questionId, answer] of Object.entries(payload.answers)) {
183
+ if (Array.isArray(answer) && answer.length > 0 && answer[0] instanceof File) {
184
+ (answer as File[]).forEach((f) => formData.append(`files[${questionId}]`, f));
185
+ normalizedAnswers[questionId] = "__isFile:true";
186
+ } else if (answer instanceof File) {
187
+ formData.append(`files[${questionId}]`, answer);
188
+ normalizedAnswers[questionId] = "__isFile:true";
189
+ } else {
190
+ normalizedAnswers[questionId] = answer;
191
+ }
192
+ }
193
+ formData.append("data", JSON.stringify({ ...payload, answers: normalizedAnswers }));
194
+ response = await fetch(`${PROXY_BASE}/tickets`, {
195
+ method: "POST",
196
+ body: formData,
197
+ });
198
+ } else {
199
+ response = await fetch(`${PROXY_BASE}/tickets`, {
200
+ method: "POST",
201
+ headers: { "Content-Type": "application/json" },
202
+ body: JSON.stringify(payload),
203
+ });
204
+ }
205
+
206
+ const responsePayload = (await response.json()) as {
207
+ error?: string;
208
+ success?: boolean;
209
+ data?: { email_limit_reached?: boolean; warning?: string };
210
+ };
211
+ if (!response.ok || !responsePayload?.success) {
212
+ throw new Error(responsePayload?.error || "Failed to create ticket.");
213
+ }
214
+
215
+ return {
216
+ emailLimitReached: responsePayload.data?.email_limit_reached ?? false,
217
+ warning: responsePayload.data?.warning,
218
+ };
219
+ }
220
+
221
+ export async function fetchTicketThread(token: string): Promise<TicketThread> {
222
+ const payload = await $fetch<{
223
+ success?: boolean;
224
+ error?: string;
225
+ error_code?: string;
226
+ data?: TicketThread;
227
+ }>(`${PROXY_BASE}/ticket?token=${encodeURIComponent(token)}`, {
228
+ method: "GET",
229
+ });
230
+
231
+ if (!payload?.success || !payload?.data) {
232
+ if (payload?.error_code === "ticket-link-expired") {
233
+ throw new Error("This link has expired. A new secure link has been sent to your email.");
234
+ }
235
+ throw new Error(payload?.error || "Failed to load ticket.");
236
+ }
237
+
238
+ return payload.data;
239
+ }
240
+
241
+ export async function sendReply(token: string, content: string, files: File[]): Promise<void> {
242
+ const url = `${PROXY_BASE}/ticket/messages?token=${encodeURIComponent(token)}`;
243
+
244
+ let response: Response;
245
+ if (files.length > 0) {
246
+ const formData = new FormData();
247
+ formData.append("data", JSON.stringify({ content }));
248
+ for (const file of files) {
249
+ formData.append("files", file);
250
+ }
251
+ response = await fetch(url, {
252
+ method: "POST",
253
+ body: formData,
254
+ });
255
+ } else {
256
+ response = await fetch(url, {
257
+ method: "POST",
258
+ headers: { "Content-Type": "application/json" },
259
+ body: JSON.stringify({ content }),
260
+ });
261
+ }
262
+
263
+ const responsePayload = (await response.json()) as {
264
+ success?: boolean;
265
+ error?: string;
266
+ error_code?: string;
267
+ };
268
+ if (!response.ok || !responsePayload?.success) {
269
+ if (responsePayload?.error_code === "ticket-link-expired") {
270
+ throw new Error("This link has expired. A new secure link has been sent to your email.");
271
+ }
272
+ throw new Error(responsePayload?.error || "Failed to send reply.");
273
+ }
274
+ }
@@ -0,0 +1,303 @@
1
+ <script setup lang="ts">
2
+ import type { Template, Article, Faq } from "~/composables/useVicket";
3
+ import { cn, stripHtml, sanitizeHtml, fetchSupportInit } from "~/composables/useVicket";
4
+
5
+ /* ---------------------------------------------- */
6
+ /* Reactive state */
7
+ /* ---------------------------------------------- */
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
+ });
87
+ </script>
88
+
89
+ <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>
301
+ </template>
302
+
303
+ <style src="~/assets/css/vicket.css"></style>