@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,257 @@
|
|
|
1
|
+
/* ── Types ─────────────────────────────────────── */
|
|
2
|
+
|
|
3
|
+
export type TemplateOption = {
|
|
4
|
+
id: string;
|
|
5
|
+
label: string;
|
|
6
|
+
value: string;
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
export type TemplateQuestion = {
|
|
10
|
+
id: string;
|
|
11
|
+
label: string;
|
|
12
|
+
type: "TEXT" | "TEXTAREA" | "SELECT" | "CHECKBOX" | "DATE" | "FILE";
|
|
13
|
+
required: boolean;
|
|
14
|
+
order: number;
|
|
15
|
+
options?: TemplateOption[];
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export type Template = {
|
|
19
|
+
id: string;
|
|
20
|
+
name: string;
|
|
21
|
+
description: string;
|
|
22
|
+
questions: TemplateQuestion[];
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export type Article = {
|
|
26
|
+
id: string;
|
|
27
|
+
title: string;
|
|
28
|
+
slug: string;
|
|
29
|
+
content: string;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export type Faq = {
|
|
33
|
+
id: string;
|
|
34
|
+
question: string;
|
|
35
|
+
answer: string;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
export type SupportInitResponse = {
|
|
39
|
+
success: boolean;
|
|
40
|
+
data?: {
|
|
41
|
+
website?: { name?: string };
|
|
42
|
+
templates: Template[];
|
|
43
|
+
articles?: Article[];
|
|
44
|
+
faqs?: Faq[];
|
|
45
|
+
};
|
|
46
|
+
error?: string;
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
export type FormValues = {
|
|
50
|
+
email: string;
|
|
51
|
+
title: string;
|
|
52
|
+
answers: Record<string, unknown>;
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
export type Attachment = {
|
|
56
|
+
id: string;
|
|
57
|
+
original_filename: string;
|
|
58
|
+
url: string;
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
export type Message = {
|
|
62
|
+
id: string;
|
|
63
|
+
content: string;
|
|
64
|
+
author_type: "reporter" | "user" | "system";
|
|
65
|
+
created_at: string;
|
|
66
|
+
attachments?: Attachment[];
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
export type TicketAnswer = {
|
|
70
|
+
id: string;
|
|
71
|
+
question_label: string;
|
|
72
|
+
answer: string;
|
|
73
|
+
attachments?: Attachment[];
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
export type TicketThread = {
|
|
77
|
+
id: string;
|
|
78
|
+
title: string;
|
|
79
|
+
status?: { label: string };
|
|
80
|
+
priority?: { label: string };
|
|
81
|
+
messages: Message[];
|
|
82
|
+
answers?: TicketAnswer[];
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
/* ── Constants ─────────────────────────────────── */
|
|
86
|
+
|
|
87
|
+
const PROXY_BASE = "/api/vicket";
|
|
88
|
+
|
|
89
|
+
export const AUTHOR_LABELS: Record<string, string> = {
|
|
90
|
+
reporter: "You",
|
|
91
|
+
user: "Support",
|
|
92
|
+
system: "System",
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
export const initialFormValues: FormValues = { email: "", title: "", answers: {} };
|
|
96
|
+
|
|
97
|
+
/* ── Helpers ───────────────────────────────────── */
|
|
98
|
+
|
|
99
|
+
export function cn(...classes: (string | boolean | undefined | null)[]): string {
|
|
100
|
+
return classes.filter(Boolean).join(" ");
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function stripHtml(html: string): string {
|
|
104
|
+
return html.replace(/<[^>]*>/g, "");
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function sanitizeHtml(html: string): string {
|
|
108
|
+
return html
|
|
109
|
+
.replace(/<script[\s\S]*?<\/script>/gi, "")
|
|
110
|
+
.replace(/\son\w+="[^"]*"/gi, "")
|
|
111
|
+
.replace(/\son\w+='[^']*'/gi, "")
|
|
112
|
+
.replace(/href\s*=\s*"javascript:[^"]*"/gi, 'href="#"')
|
|
113
|
+
.replace(/href\s*=\s*'javascript:[^']*'/gi, "href='#'");
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function formatDate(iso: string): string {
|
|
117
|
+
try {
|
|
118
|
+
return new Intl.DateTimeFormat("en", {
|
|
119
|
+
month: "short",
|
|
120
|
+
day: "numeric",
|
|
121
|
+
hour: "numeric",
|
|
122
|
+
minute: "2-digit",
|
|
123
|
+
}).format(new Date(iso));
|
|
124
|
+
} catch {
|
|
125
|
+
return iso;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export function isFileAnswer(answer: string): boolean {
|
|
130
|
+
return answer?.includes("__isFile:true") || answer?.includes("map[__isFile");
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export function formatAnswerText(value: string): string {
|
|
134
|
+
if (!value) return "";
|
|
135
|
+
const trimmed = value.trim();
|
|
136
|
+
if (trimmed.startsWith("[") && trimmed.endsWith("]")) {
|
|
137
|
+
const rawItems = trimmed.slice(1, -1).trim();
|
|
138
|
+
return rawItems.length > 0 ? rawItems.split(/\s+/).join(", ") : "";
|
|
139
|
+
}
|
|
140
|
+
return value;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/* ── API client ────────────────────────────────── */
|
|
144
|
+
|
|
145
|
+
export async function fetchInit(): Promise<NonNullable<SupportInitResponse["data"]>> {
|
|
146
|
+
const response = await fetch(`${PROXY_BASE}/init`, {
|
|
147
|
+
method: "GET",
|
|
148
|
+
cache: "no-store",
|
|
149
|
+
headers: { "Content-Type": "application/json" },
|
|
150
|
+
});
|
|
151
|
+
const payload = (await response.json()) as SupportInitResponse;
|
|
152
|
+
if (!response.ok || !payload?.success || !payload?.data) {
|
|
153
|
+
throw new Error(payload?.error || "Failed to load support data.");
|
|
154
|
+
}
|
|
155
|
+
return payload.data;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export async function createTicket(body: {
|
|
159
|
+
email: string;
|
|
160
|
+
title: string;
|
|
161
|
+
templateId: string;
|
|
162
|
+
answers: Record<string, unknown>;
|
|
163
|
+
hasFiles: boolean;
|
|
164
|
+
fileQuestionIds: string[];
|
|
165
|
+
}): Promise<{ emailLimitReached?: boolean; warning?: string }> {
|
|
166
|
+
const payload = {
|
|
167
|
+
email: body.email,
|
|
168
|
+
title: body.title,
|
|
169
|
+
templateId: body.templateId,
|
|
170
|
+
answers: { ...body.answers },
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
let response: Response;
|
|
174
|
+
if (body.hasFiles) {
|
|
175
|
+
const formData = new FormData();
|
|
176
|
+
const normalizedAnswers: Record<string, unknown> = {};
|
|
177
|
+
for (const [questionId, answer] of Object.entries(payload.answers)) {
|
|
178
|
+
if (answer instanceof File) {
|
|
179
|
+
formData.append(`files[${questionId}]`, answer);
|
|
180
|
+
normalizedAnswers[questionId] = "__isFile:true";
|
|
181
|
+
} else {
|
|
182
|
+
normalizedAnswers[questionId] = answer;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
formData.append("data", JSON.stringify({ ...payload, answers: normalizedAnswers }));
|
|
186
|
+
response = await fetch(`${PROXY_BASE}/tickets`, { method: "POST", body: formData });
|
|
187
|
+
} else {
|
|
188
|
+
response = await fetch(`${PROXY_BASE}/tickets`, {
|
|
189
|
+
method: "POST",
|
|
190
|
+
headers: { "Content-Type": "application/json" },
|
|
191
|
+
body: JSON.stringify(payload),
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const responsePayload = (await response.json()) as {
|
|
196
|
+
error?: string;
|
|
197
|
+
success?: boolean;
|
|
198
|
+
data?: { email_limit_reached?: boolean; warning?: string };
|
|
199
|
+
};
|
|
200
|
+
if (!response.ok || !responsePayload?.success) {
|
|
201
|
+
throw new Error(responsePayload?.error || "Failed to create ticket.");
|
|
202
|
+
}
|
|
203
|
+
return {
|
|
204
|
+
emailLimitReached: responsePayload.data?.email_limit_reached ?? false,
|
|
205
|
+
warning: responsePayload.data?.warning,
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
export async function fetchThread(token: string): Promise<TicketThread> {
|
|
210
|
+
const response = await fetch(
|
|
211
|
+
`${PROXY_BASE}/thread?token=${encodeURIComponent(token)}`,
|
|
212
|
+
{ method: "GET", cache: "no-store", headers: { "Content-Type": "application/json" } },
|
|
213
|
+
);
|
|
214
|
+
const payload = (await response.json()) as {
|
|
215
|
+
success?: boolean;
|
|
216
|
+
error?: string;
|
|
217
|
+
error_code?: string;
|
|
218
|
+
data?: TicketThread;
|
|
219
|
+
};
|
|
220
|
+
if (!response.ok || !payload?.success || !payload?.data) {
|
|
221
|
+
if (payload?.error_code === "ticket-link-expired") {
|
|
222
|
+
throw new Error("This link has expired. A new secure link has been sent to your email.");
|
|
223
|
+
}
|
|
224
|
+
throw new Error(payload?.error || "Failed to load ticket.");
|
|
225
|
+
}
|
|
226
|
+
return payload.data;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
export async function sendReply(token: string, content: string, files: File[]): Promise<void> {
|
|
230
|
+
const url = `${PROXY_BASE}/messages?token=${encodeURIComponent(token)}`;
|
|
231
|
+
|
|
232
|
+
let response: Response;
|
|
233
|
+
if (files.length > 0) {
|
|
234
|
+
const formData = new FormData();
|
|
235
|
+
formData.append("data", JSON.stringify({ content }));
|
|
236
|
+
for (const file of files) formData.append("files", file);
|
|
237
|
+
response = await fetch(url, { method: "POST", body: formData });
|
|
238
|
+
} else {
|
|
239
|
+
response = await fetch(url, {
|
|
240
|
+
method: "POST",
|
|
241
|
+
headers: { "Content-Type": "application/json" },
|
|
242
|
+
body: JSON.stringify({ content }),
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
const payload = (await response.json()) as {
|
|
247
|
+
success?: boolean;
|
|
248
|
+
error?: string;
|
|
249
|
+
error_code?: string;
|
|
250
|
+
};
|
|
251
|
+
if (!response.ok || !payload?.success) {
|
|
252
|
+
if (payload?.error_code === "ticket-link-expired") {
|
|
253
|
+
throw new Error("This link has expired. A new secure link has been sent to your email.");
|
|
254
|
+
}
|
|
255
|
+
throw new Error(payload?.error || "Failed to send reply.");
|
|
256
|
+
}
|
|
257
|
+
}
|
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
/* ── Reactive state ─────────────────────────────── */
|
|
3
|
+
const templates = ref<Template[]>([]);
|
|
4
|
+
const articles = ref<Article[]>([]);
|
|
5
|
+
const faqs = ref<Faq[]>([]);
|
|
6
|
+
const websiteName = ref("Support");
|
|
7
|
+
const isLoading = ref(true);
|
|
8
|
+
const error = ref("");
|
|
9
|
+
const searchQuery = ref("");
|
|
10
|
+
const dialogOpen = ref(false);
|
|
11
|
+
const selectedArticle = ref<Article | null>(null);
|
|
12
|
+
|
|
13
|
+
/* FAQ accordion open state */
|
|
14
|
+
const openFaqIds = ref<Set<string>>(new Set());
|
|
15
|
+
|
|
16
|
+
/* ── Computed ───────────────────────────────────── */
|
|
17
|
+
const filteredArticles = computed(() => {
|
|
18
|
+
if (!searchQuery.value.trim()) return articles.value;
|
|
19
|
+
const q = searchQuery.value.toLowerCase();
|
|
20
|
+
return articles.value.filter(
|
|
21
|
+
(a) =>
|
|
22
|
+
a.title.toLowerCase().includes(q) ||
|
|
23
|
+
stripHtml(a.content).toLowerCase().includes(q),
|
|
24
|
+
);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
const filteredFaqs = computed(() => {
|
|
28
|
+
if (!searchQuery.value.trim()) return faqs.value;
|
|
29
|
+
const q = searchQuery.value.toLowerCase();
|
|
30
|
+
return faqs.value.filter(
|
|
31
|
+
(f) =>
|
|
32
|
+
f.question.toLowerCase().includes(q) ||
|
|
33
|
+
f.answer.toLowerCase().includes(q),
|
|
34
|
+
);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
const hasContent = computed(() => articles.value.length > 0 || faqs.value.length > 0);
|
|
38
|
+
const hasResults = computed(() => filteredArticles.value.length > 0 || filteredFaqs.value.length > 0);
|
|
39
|
+
|
|
40
|
+
/* ── FAQ accordion ──────────────────────────────── */
|
|
41
|
+
const toggleFaq = (id: string) => {
|
|
42
|
+
const next = new Set(openFaqIds.value);
|
|
43
|
+
if (next.has(id)) {
|
|
44
|
+
next.delete(id);
|
|
45
|
+
} else {
|
|
46
|
+
next.add(id);
|
|
47
|
+
}
|
|
48
|
+
openFaqIds.value = next;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
const isFaqOpen = (id: string) => openFaqIds.value.has(id);
|
|
52
|
+
|
|
53
|
+
/* ── Data loading ───────────────────────────────── */
|
|
54
|
+
onMounted(async () => {
|
|
55
|
+
isLoading.value = true;
|
|
56
|
+
error.value = "";
|
|
57
|
+
try {
|
|
58
|
+
const data = await fetchInit();
|
|
59
|
+
templates.value = data.templates || [];
|
|
60
|
+
articles.value = data.articles || [];
|
|
61
|
+
faqs.value = data.faqs || [];
|
|
62
|
+
websiteName.value = data.website?.name || "Support";
|
|
63
|
+
} catch (loadError) {
|
|
64
|
+
error.value = loadError instanceof Error ? loadError.message : "Unexpected error.";
|
|
65
|
+
} finally {
|
|
66
|
+
isLoading.value = false;
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
</script>
|
|
70
|
+
|
|
71
|
+
<template>
|
|
72
|
+
<div class="min-h-screen bg-slate-50 font-[system-ui,-apple-system,sans-serif] antialiased">
|
|
73
|
+
<!-- Skeleton loading state -->
|
|
74
|
+
<div v-if="isLoading">
|
|
75
|
+
<div class="mx-auto flex max-w-5xl flex-col gap-6 px-6 pt-14 md:flex-row md:items-center md:justify-between">
|
|
76
|
+
<div class="flex-1">
|
|
77
|
+
<div class="mb-3 h-9 w-48 animate-pulse rounded-lg bg-slate-200" />
|
|
78
|
+
<div class="h-5 w-64 animate-pulse rounded-md bg-slate-200" />
|
|
79
|
+
</div>
|
|
80
|
+
<div class="h-10 w-40 animate-pulse rounded-full bg-slate-200" />
|
|
81
|
+
</div>
|
|
82
|
+
<div class="mx-auto mt-8 max-w-5xl px-6">
|
|
83
|
+
<div class="h-11 w-full animate-pulse rounded-xl bg-slate-200" />
|
|
84
|
+
</div>
|
|
85
|
+
<div class="mx-auto mt-8 grid max-w-5xl grid-cols-1 gap-8 px-6 md:grid-cols-2">
|
|
86
|
+
<div class="space-y-3">
|
|
87
|
+
<div class="h-5 w-36 animate-pulse rounded bg-slate-200" />
|
|
88
|
+
<div v-for="i in 3" :key="i" class="h-20 animate-pulse rounded-xl bg-slate-200" />
|
|
89
|
+
</div>
|
|
90
|
+
<div class="space-y-3">
|
|
91
|
+
<div class="h-5 w-52 animate-pulse rounded bg-slate-200" />
|
|
92
|
+
<div v-for="i in 3" :key="i" class="h-14 animate-pulse rounded-xl bg-slate-200" />
|
|
93
|
+
</div>
|
|
94
|
+
</div>
|
|
95
|
+
</div>
|
|
96
|
+
|
|
97
|
+
<!-- Article detail view -->
|
|
98
|
+
<div v-else-if="selectedArticle" class="mx-auto max-w-5xl px-6">
|
|
99
|
+
<div class="flex flex-col gap-4 pb-8 pt-14 md:flex-row md:items-center md:justify-between">
|
|
100
|
+
<div>
|
|
101
|
+
<h1 class="m-0 text-2xl font-bold tracking-tight text-slate-900 md:text-3xl">{{ websiteName }}</h1>
|
|
102
|
+
<p class="mt-1.5 text-slate-500">How can we help you today?</p>
|
|
103
|
+
</div>
|
|
104
|
+
<button
|
|
105
|
+
v-if="templates.length > 0"
|
|
106
|
+
type="button"
|
|
107
|
+
class="inline-flex shrink-0 items-center gap-2 !rounded-full border-none bg-blue-600 !px-7 !py-3 text-sm font-semibold text-white cursor-pointer transition-all hover:bg-blue-700 hover:-translate-y-px hover:shadow-lg active:translate-y-0"
|
|
108
|
+
@click="dialogOpen = true"
|
|
109
|
+
>
|
|
110
|
+
<!-- IconMessageCircle -->
|
|
111
|
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M7.9 20A9 9 0 1 0 4 16.1L2 22Z" /></svg>
|
|
112
|
+
Contact Support
|
|
113
|
+
</button>
|
|
114
|
+
</div>
|
|
115
|
+
|
|
116
|
+
<div class="pb-16">
|
|
117
|
+
<button
|
|
118
|
+
type="button"
|
|
119
|
+
class="-ml-2 mb-4 inline-flex cursor-pointer items-center gap-1.5 rounded-lg border-none bg-transparent px-2 py-1 text-sm font-medium text-slate-500 transition-colors hover:bg-white hover:text-slate-900"
|
|
120
|
+
@click="selectedArticle = null"
|
|
121
|
+
>
|
|
122
|
+
<!-- IconArrowLeft -->
|
|
123
|
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="m12 19-7-7 7-7" /><path d="M19 12H5" /></svg>
|
|
124
|
+
Back to articles
|
|
125
|
+
</button>
|
|
126
|
+
|
|
127
|
+
<div class="rounded-2xl border border-slate-200 bg-white p-6 shadow-sm md:p-8">
|
|
128
|
+
<h2 class="m-0 text-xl font-bold tracking-tight text-slate-900 md:text-2xl">{{ selectedArticle.title }}</h2>
|
|
129
|
+
<div
|
|
130
|
+
class="vk-message-content mt-5 text-sm leading-relaxed text-slate-500"
|
|
131
|
+
v-html="sanitizeHtml(selectedArticle.content)"
|
|
132
|
+
/>
|
|
133
|
+
</div>
|
|
134
|
+
</div>
|
|
135
|
+
|
|
136
|
+
<VicketTicketDialog
|
|
137
|
+
:open="dialogOpen"
|
|
138
|
+
:templates="templates"
|
|
139
|
+
@close="dialogOpen = false"
|
|
140
|
+
/>
|
|
141
|
+
</div>
|
|
142
|
+
|
|
143
|
+
<!-- Main hub view -->
|
|
144
|
+
<div v-else class="mx-auto max-w-5xl px-6">
|
|
145
|
+
<div class="flex flex-col gap-4 pb-8 pt-14 md:flex-row md:items-center md:justify-between">
|
|
146
|
+
<div>
|
|
147
|
+
<h1 class="m-0 text-2xl font-bold tracking-tight text-slate-900 md:text-3xl">{{ websiteName }}</h1>
|
|
148
|
+
<p class="mt-1.5 text-slate-500">How can we help you today?</p>
|
|
149
|
+
</div>
|
|
150
|
+
<div v-if="templates.length > 0" class="flex items-center gap-3">
|
|
151
|
+
<span class="hidden text-sm text-slate-500 md:block">Can't find what you're looking for?</span>
|
|
152
|
+
<button
|
|
153
|
+
type="button"
|
|
154
|
+
class="inline-flex shrink-0 items-center gap-2 !rounded-full border-none bg-blue-600 !px-7 !py-3 text-sm font-semibold text-white cursor-pointer transition-all hover:bg-blue-700 hover:-translate-y-px hover:shadow-lg active:translate-y-0"
|
|
155
|
+
@click="dialogOpen = true"
|
|
156
|
+
>
|
|
157
|
+
<!-- IconMessageCircle -->
|
|
158
|
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M7.9 20A9 9 0 1 0 4 16.1L2 22Z" /></svg>
|
|
159
|
+
Contact Support
|
|
160
|
+
</button>
|
|
161
|
+
</div>
|
|
162
|
+
</div>
|
|
163
|
+
|
|
164
|
+
<!-- Error alert -->
|
|
165
|
+
<div v-if="error" class="pb-4">
|
|
166
|
+
<div
|
|
167
|
+
class="flex items-start gap-3 rounded-xl border border-red-200 bg-red-50 p-4 text-sm text-red-900"
|
|
168
|
+
role="alert"
|
|
169
|
+
>
|
|
170
|
+
<span class="mt-0.5 shrink-0">
|
|
171
|
+
<!-- IconAlert -->
|
|
172
|
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3Z" /><path d="M12 9v4" /><path d="M12 17h.01" /></svg>
|
|
173
|
+
</span>
|
|
174
|
+
<span class="flex-1">{{ error }}</span>
|
|
175
|
+
<button
|
|
176
|
+
type="button"
|
|
177
|
+
class="shrink-0 cursor-pointer border-none bg-transparent p-0 opacity-50 transition-opacity hover:opacity-100"
|
|
178
|
+
aria-label="Dismiss"
|
|
179
|
+
@click="error = ''"
|
|
180
|
+
>
|
|
181
|
+
<!-- IconX -->
|
|
182
|
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M18 6 6 18" /><path d="m6 6 12 12" /></svg>
|
|
183
|
+
</button>
|
|
184
|
+
</div>
|
|
185
|
+
</div>
|
|
186
|
+
|
|
187
|
+
<!-- Search bar -->
|
|
188
|
+
<div v-if="hasContent" class="pb-8">
|
|
189
|
+
<div class="relative">
|
|
190
|
+
<span class="pointer-events-none absolute left-3.5 top-1/2 -translate-y-1/2 text-slate-500">
|
|
191
|
+
<!-- IconSearch -->
|
|
192
|
+
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8" /><path d="m21 21-4.3-4.3" /></svg>
|
|
193
|
+
</span>
|
|
194
|
+
<input
|
|
195
|
+
type="text"
|
|
196
|
+
class="w-full rounded-xl border border-slate-200 bg-white py-3 pl-11 pr-4 text-sm text-slate-900 shadow-sm transition-all duration-150 placeholder:text-slate-500/60 focus:border-blue-600 focus:outline-none focus:ring-3 focus:ring-blue-600/10"
|
|
197
|
+
placeholder="Search articles and FAQs..."
|
|
198
|
+
v-model="searchQuery"
|
|
199
|
+
/>
|
|
200
|
+
</div>
|
|
201
|
+
</div>
|
|
202
|
+
|
|
203
|
+
<!-- Content grid -->
|
|
204
|
+
<div v-if="hasResults" class="grid grid-cols-1 gap-10 pb-16 md:grid-cols-2 md:gap-12">
|
|
205
|
+
<!-- Articles -->
|
|
206
|
+
<div v-if="filteredArticles.length > 0">
|
|
207
|
+
<div class="mb-4 flex items-center gap-2">
|
|
208
|
+
<span class="text-blue-600">
|
|
209
|
+
<!-- IconArticle 18 -->
|
|
210
|
+
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8Z" /><path d="M14 2v6h6" /><path d="M16 13H8" /><path d="M16 17H8" /><path d="M10 9H8" /></svg>
|
|
211
|
+
</span>
|
|
212
|
+
<h2 class="m-0 text-base font-bold tracking-tight text-slate-900">Popular Articles</h2>
|
|
213
|
+
</div>
|
|
214
|
+
<div class="space-y-1">
|
|
215
|
+
<button
|
|
216
|
+
v-for="article in filteredArticles"
|
|
217
|
+
:key="article.id"
|
|
218
|
+
type="button"
|
|
219
|
+
class="group flex w-full cursor-pointer items-start gap-3 rounded-xl border border-transparent bg-transparent p-3 text-left transition-all duration-150 hover:border-slate-200 hover:bg-white hover:shadow-sm"
|
|
220
|
+
@click="selectedArticle = article"
|
|
221
|
+
>
|
|
222
|
+
<div class="flex h-9 w-9 min-w-9 items-center justify-center rounded-lg bg-blue-50 text-blue-600">
|
|
223
|
+
<!-- IconArticle 16 -->
|
|
224
|
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8Z" /><path d="M14 2v6h6" /><path d="M16 13H8" /><path d="M16 17H8" /><path d="M10 9H8" /></svg>
|
|
225
|
+
</div>
|
|
226
|
+
<div class="min-w-0 flex-1">
|
|
227
|
+
<h3 class="m-0 text-sm font-semibold leading-snug text-slate-900 transition-colors group-hover:text-blue-600">{{ article.title }}</h3>
|
|
228
|
+
<p v-if="article.content" class="mt-0.5 line-clamp-2 text-xs leading-normal text-slate-500">
|
|
229
|
+
{{ stripHtml(article.content).substring(0, 150) }}
|
|
230
|
+
</p>
|
|
231
|
+
</div>
|
|
232
|
+
</button>
|
|
233
|
+
</div>
|
|
234
|
+
</div>
|
|
235
|
+
|
|
236
|
+
<!-- FAQs -->
|
|
237
|
+
<div v-if="filteredFaqs.length > 0">
|
|
238
|
+
<div class="mb-4 flex items-center gap-2">
|
|
239
|
+
<span class="text-blue-600">
|
|
240
|
+
<!-- IconHelpCircle -->
|
|
241
|
+
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10" /><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3" /><path d="M12 17h.01" /></svg>
|
|
242
|
+
</span>
|
|
243
|
+
<h2 class="m-0 text-base font-bold tracking-tight text-slate-900">Frequently Asked Questions</h2>
|
|
244
|
+
</div>
|
|
245
|
+
<div class="space-y-2">
|
|
246
|
+
<div
|
|
247
|
+
v-for="faq in filteredFaqs"
|
|
248
|
+
:key="faq.id"
|
|
249
|
+
class="rounded-xl border border-slate-200 bg-white/50 transition-colors hover:bg-white/80"
|
|
250
|
+
>
|
|
251
|
+
<button
|
|
252
|
+
type="button"
|
|
253
|
+
:class="cn(
|
|
254
|
+
'flex w-full cursor-pointer items-center justify-between gap-3 border-none bg-transparent px-4 py-3.5 text-left text-sm font-semibold text-slate-900',
|
|
255
|
+
isFaqOpen(faq.id) && 'text-blue-600',
|
|
256
|
+
)"
|
|
257
|
+
:aria-expanded="isFaqOpen(faq.id)"
|
|
258
|
+
@click="toggleFaq(faq.id)"
|
|
259
|
+
>
|
|
260
|
+
{{ faq.question }}
|
|
261
|
+
<span :class="cn('shrink-0 text-slate-500 transition-transform duration-200', isFaqOpen(faq.id) && 'rotate-180')">
|
|
262
|
+
<!-- IconChevronDown -->
|
|
263
|
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="m6 9 6 6 6-6" /></svg>
|
|
264
|
+
</span>
|
|
265
|
+
</button>
|
|
266
|
+
<div :class="cn('grid grid-rows-[0fr] transition-[grid-template-rows] duration-250', isFaqOpen(faq.id) && 'grid-rows-[1fr]')">
|
|
267
|
+
<div class="overflow-hidden">
|
|
268
|
+
<div class="whitespace-pre-wrap px-4 pb-4 text-sm leading-relaxed text-slate-500">
|
|
269
|
+
{{ faq.answer }}
|
|
270
|
+
</div>
|
|
271
|
+
</div>
|
|
272
|
+
</div>
|
|
273
|
+
</div>
|
|
274
|
+
</div>
|
|
275
|
+
</div>
|
|
276
|
+
</div>
|
|
277
|
+
|
|
278
|
+
<!-- No search results -->
|
|
279
|
+
<div v-if="searchQuery.trim() && !hasResults" class="pb-16 text-center">
|
|
280
|
+
<p class="text-sm text-slate-500">No results found for “{{ searchQuery }}”</p>
|
|
281
|
+
</div>
|
|
282
|
+
|
|
283
|
+
<!-- Empty state -->
|
|
284
|
+
<div v-if="!hasContent && !error" class="pb-16 pt-4 text-center">
|
|
285
|
+
<div class="mx-auto max-w-sm rounded-2xl border border-slate-200 bg-white p-8 shadow-sm">
|
|
286
|
+
<div class="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-blue-50 text-blue-600">
|
|
287
|
+
<!-- IconMessageCircle 22 -->
|
|
288
|
+
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M7.9 20A9 9 0 1 0 4 16.1L2 22Z" /></svg>
|
|
289
|
+
</div>
|
|
290
|
+
<p class="m-0 text-sm font-medium text-slate-900">Need help? Our team is here for you.</p>
|
|
291
|
+
<button
|
|
292
|
+
type="button"
|
|
293
|
+
class="mt-5 inline-flex items-center gap-2 !rounded-full border-none bg-blue-600 !px-7 !py-3 text-sm font-semibold text-white cursor-pointer transition-all hover:bg-blue-700 hover:-translate-y-px hover:shadow-lg active:translate-y-0"
|
|
294
|
+
@click="dialogOpen = true"
|
|
295
|
+
>
|
|
296
|
+
Contact Support
|
|
297
|
+
</button>
|
|
298
|
+
</div>
|
|
299
|
+
</div>
|
|
300
|
+
|
|
301
|
+
<VicketTicketDialog
|
|
302
|
+
:open="dialogOpen"
|
|
303
|
+
:templates="templates"
|
|
304
|
+
@close="dialogOpen = false"
|
|
305
|
+
/>
|
|
306
|
+
</div>
|
|
307
|
+
</div>
|
|
308
|
+
</template>
|
|
309
|
+
|
|
310
|
+
<style>
|
|
311
|
+
.vk-message-content p { margin-bottom: 0.5rem; }
|
|
312
|
+
.vk-message-content p:last-child { margin-bottom: 0; }
|
|
313
|
+
.vk-message-content a { color: #2563eb; text-decoration: underline; }
|
|
314
|
+
.vk-message-content ul, .vk-message-content ol { margin: 0.25rem 0; padding-left: 1.5rem; }
|
|
315
|
+
.vk-message-content pre { overflow-x: auto; border-radius: 6px; background: #f8fafc; padding: 0.75rem; font-size: 0.75rem; }
|
|
316
|
+
.vk-message-content code { font-family: monospace; font-size: 0.85em; }
|
|
317
|
+
</style>
|