@vicket/create-support 1.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +52 -0
- package/bin/create-vicket-support.js +389 -0
- package/package.json +18 -0
- package/templates/next/src/app/api/vicket/[...path]/route.ts +59 -0
- package/templates/next/src/app/components/vicket/TicketDialog.tsx +514 -0
- package/templates/next/src/app/support/page.tsx +358 -0
- package/templates/next/src/app/ticket/page.tsx +483 -0
- package/templates/next/src/app/utils/vicket/api.ts +149 -0
- package/templates/next/src/app/utils/vicket/types.ts +85 -0
- package/templates/next/src/app/utils/vicket/utils.ts +49 -0
- package/templates/next/src/app/vicket.css +1325 -0
- package/templates/nuxt/app/assets/css/vicket.css +1325 -0
- package/templates/nuxt/app/components/VicketTicketDialog.vue +499 -0
- package/templates/nuxt/app/composables/useVicket.ts +274 -0
- package/templates/nuxt/app/pages/support.vue +303 -0
- package/templates/nuxt/app/pages/ticket.vue +434 -0
- package/templates/nuxt/server/api/vicket/[...path].ts +85 -0
- package/templates/sveltekit/src/lib/vicket/TicketDialog.svelte +459 -0
- package/templates/sveltekit/src/lib/vicket/api.ts +162 -0
- package/templates/sveltekit/src/lib/vicket/types.ts +87 -0
- package/templates/sveltekit/src/lib/vicket/utils.ts +55 -0
- package/templates/sveltekit/src/lib/vicket.css +1325 -0
- package/templates/sveltekit/src/routes/api/vicket/[...path]/+server.ts +77 -0
- package/templates/sveltekit/src/routes/support/+page.svelte +316 -0
- package/templates/sveltekit/src/routes/ticket/+page.svelte +418 -0
- package/templates-tailwind/next/src/app/api/vicket/init/route.ts +24 -0
- package/templates-tailwind/next/src/app/api/vicket/messages/route.ts +36 -0
- package/templates-tailwind/next/src/app/api/vicket/thread/route.ts +27 -0
- package/templates-tailwind/next/src/app/api/vicket/tickets/route.ts +37 -0
- package/templates-tailwind/next/src/app/support/page.tsx +5 -0
- package/templates-tailwind/next/src/app/ticket/page.tsx +10 -0
- package/templates-tailwind/next/src/components/vicket/support-page.tsx +359 -0
- package/templates-tailwind/next/src/components/vicket/ticket-dialog.tsx +306 -0
- package/templates-tailwind/next/src/components/vicket/ticket-page.tsx +425 -0
- package/templates-tailwind/next/src/lib/vicket.ts +257 -0
- package/templates-tailwind/nuxt/app/components/VicketSupportPage.vue +317 -0
- package/templates-tailwind/nuxt/app/components/VicketTicketDialog.vue +444 -0
- package/templates-tailwind/nuxt/app/components/VicketTicketPage.vue +449 -0
- package/templates-tailwind/nuxt/app/composables/use-vicket.ts +249 -0
- package/templates-tailwind/nuxt/app/pages/support.vue +3 -0
- package/templates-tailwind/nuxt/app/pages/ticket.vue +3 -0
- package/templates-tailwind/nuxt/server/api/vicket/init.get.ts +22 -0
- package/templates-tailwind/nuxt/server/api/vicket/messages.post.ts +56 -0
- package/templates-tailwind/nuxt/server/api/vicket/thread.get.ts +26 -0
- package/templates-tailwind/nuxt/server/api/vicket/tickets.post.ts +53 -0
- package/templates-tailwind/sveltekit/src/lib/vicket/SupportPage.svelte +395 -0
- package/templates-tailwind/sveltekit/src/lib/vicket/TicketDialog.svelte +406 -0
- package/templates-tailwind/sveltekit/src/lib/vicket/TicketPage.svelte +465 -0
- package/templates-tailwind/sveltekit/src/lib/vicket/index.ts +257 -0
- package/templates-tailwind/sveltekit/src/routes/api/vicket/init/+server.ts +22 -0
- package/templates-tailwind/sveltekit/src/routes/api/vicket/messages/+server.ts +40 -0
- package/templates-tailwind/sveltekit/src/routes/api/vicket/thread/+server.ts +25 -0
- package/templates-tailwind/sveltekit/src/routes/api/vicket/tickets/+server.ts +37 -0
- package/templates-tailwind/sveltekit/src/routes/support/+page.svelte +5 -0
- package/templates-tailwind/sveltekit/src/routes/ticket/+page.svelte +5 -0
|
@@ -0,0 +1,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
|
+
|
|
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>
|
|
301
|
+
</template>
|
|
302
|
+
|
|
303
|
+
<style src="~/assets/css/vicket.css"></style>
|