@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,77 +1,3 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { json } from '@sveltejs/kit';
|
|
3
|
-
import type { RequestHandler } from './$types';
|
|
1
|
+
import { createVicketProxy } from "vicket/sveltekit";
|
|
4
2
|
|
|
5
|
-
const
|
|
6
|
-
|
|
7
|
-
export const GET: RequestHandler = async ({ params, url }) => {
|
|
8
|
-
if (!baseUrl || !VICKET_API_KEY) {
|
|
9
|
-
return json({ success: false, error: 'Server misconfigured: missing VICKET_API_URL or VICKET_API_KEY.' }, { status: 500 });
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
const path = params.path || '';
|
|
13
|
-
const query = url.searchParams.toString();
|
|
14
|
-
const target = `${baseUrl}/public/support/${path}${query ? `?${query}` : ''}`;
|
|
15
|
-
|
|
16
|
-
const response = await fetch(target, {
|
|
17
|
-
method: 'GET',
|
|
18
|
-
headers: {
|
|
19
|
-
'X-Api-Key': VICKET_API_KEY,
|
|
20
|
-
'Content-Type': 'application/json',
|
|
21
|
-
},
|
|
22
|
-
});
|
|
23
|
-
|
|
24
|
-
const body = await response.text();
|
|
25
|
-
|
|
26
|
-
return new Response(body, {
|
|
27
|
-
status: response.status,
|
|
28
|
-
headers: {
|
|
29
|
-
'Content-Type': response.headers.get('Content-Type') || 'application/json',
|
|
30
|
-
},
|
|
31
|
-
});
|
|
32
|
-
};
|
|
33
|
-
|
|
34
|
-
export const POST: RequestHandler = async ({ params, url, request }) => {
|
|
35
|
-
if (!baseUrl || !VICKET_API_KEY) {
|
|
36
|
-
return json({ success: false, error: 'Server misconfigured: missing VICKET_API_URL or VICKET_API_KEY.' }, { status: 500 });
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
const path = params.path || '';
|
|
40
|
-
const query = url.searchParams.toString();
|
|
41
|
-
const target = `${baseUrl}/public/support/${path}${query ? `?${query}` : ''}`;
|
|
42
|
-
|
|
43
|
-
const contentType = request.headers.get('Content-Type') || '';
|
|
44
|
-
const isFormData = contentType.includes('multipart/form-data');
|
|
45
|
-
|
|
46
|
-
let response: Response;
|
|
47
|
-
|
|
48
|
-
if (isFormData) {
|
|
49
|
-
const formData = await request.formData();
|
|
50
|
-
response = await fetch(target, {
|
|
51
|
-
method: 'POST',
|
|
52
|
-
headers: {
|
|
53
|
-
'X-Api-Key': VICKET_API_KEY,
|
|
54
|
-
},
|
|
55
|
-
body: formData,
|
|
56
|
-
});
|
|
57
|
-
} else {
|
|
58
|
-
const body = await request.text();
|
|
59
|
-
response = await fetch(target, {
|
|
60
|
-
method: 'POST',
|
|
61
|
-
headers: {
|
|
62
|
-
'X-Api-Key': VICKET_API_KEY,
|
|
63
|
-
'Content-Type': 'application/json',
|
|
64
|
-
},
|
|
65
|
-
body,
|
|
66
|
-
});
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
const responseBody = await response.text();
|
|
70
|
-
|
|
71
|
-
return new Response(responseBody, {
|
|
72
|
-
status: response.status,
|
|
73
|
-
headers: {
|
|
74
|
-
'Content-Type': response.headers.get('Content-Type') || 'application/json',
|
|
75
|
-
},
|
|
76
|
-
});
|
|
77
|
-
};
|
|
3
|
+
export const { GET, POST } = createVicketProxy();
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { VICKET_API_URL, VICKET_API_KEY } from '$env/static/private';
|
|
2
|
+
import { createServerClient } from 'vicket/server';
|
|
3
|
+
import type { PageServerLoad } from './$types';
|
|
4
|
+
|
|
5
|
+
export const load: PageServerLoad = async () => {
|
|
6
|
+
try {
|
|
7
|
+
const vicket = createServerClient({ apiUrl: VICKET_API_URL, apiKey: VICKET_API_KEY });
|
|
8
|
+
const initData = await vicket.fetchInit();
|
|
9
|
+
return { initData, initError: '' };
|
|
10
|
+
} catch (e) {
|
|
11
|
+
return { initData: null, initError: e instanceof Error ? e.message : 'Failed to load support data.' };
|
|
12
|
+
}
|
|
13
|
+
};
|
|
@@ -1,316 +1,7 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
|
-
import
|
|
3
|
-
import type { Article, Faq, Template } from "$lib/vicket/types";
|
|
4
|
-
import { cn, stripHtml, sanitizeHtml } from "$lib/vicket/utils";
|
|
5
|
-
import { fetchSupportInit } from "$lib/vicket/api";
|
|
6
|
-
import TicketDialog from "$lib/vicket/TicketDialog.svelte";
|
|
2
|
+
import SupportContent from "$lib/vicket/SupportContent.svelte";
|
|
7
3
|
|
|
8
|
-
|
|
9
|
-
/* State */
|
|
10
|
-
/* ---------------------------------------------- */
|
|
11
|
-
let templates = $state<Template[]>([]);
|
|
12
|
-
let articles = $state<Article[]>([]);
|
|
13
|
-
let faqs = $state<Faq[]>([]);
|
|
14
|
-
let websiteName = $state("Support");
|
|
15
|
-
let isLoading = $state(true);
|
|
16
|
-
let error = $state("");
|
|
17
|
-
let searchQuery = $state("");
|
|
18
|
-
let dialogOpen = $state(false);
|
|
19
|
-
let selectedArticle = $state<Article | null>(null);
|
|
20
|
-
|
|
21
|
-
/* FAQ open state tracking */
|
|
22
|
-
let openFaqIds = $state<Set<string>>(new Set());
|
|
23
|
-
|
|
24
|
-
/* ---------------------------------------------- */
|
|
25
|
-
/* Derived */
|
|
26
|
-
/* ---------------------------------------------- */
|
|
27
|
-
let filteredArticles = $derived.by(() => {
|
|
28
|
-
if (!searchQuery.trim()) return articles;
|
|
29
|
-
const q = searchQuery.toLowerCase();
|
|
30
|
-
return articles.filter(
|
|
31
|
-
(a) => a.title.toLowerCase().includes(q) || stripHtml(a.content).toLowerCase().includes(q),
|
|
32
|
-
);
|
|
33
|
-
});
|
|
34
|
-
|
|
35
|
-
let filteredFaqs = $derived.by(() => {
|
|
36
|
-
if (!searchQuery.trim()) return faqs;
|
|
37
|
-
const q = searchQuery.toLowerCase();
|
|
38
|
-
return faqs.filter(
|
|
39
|
-
(f) => f.question.toLowerCase().includes(q) || f.answer.toLowerCase().includes(q),
|
|
40
|
-
);
|
|
41
|
-
});
|
|
42
|
-
|
|
43
|
-
let hasContent = $derived(articles.length > 0 || faqs.length > 0);
|
|
44
|
-
let hasResults = $derived(filteredArticles.length > 0 || filteredFaqs.length > 0);
|
|
45
|
-
|
|
46
|
-
/* ---------------------------------------------- */
|
|
47
|
-
/* Functions */
|
|
48
|
-
/* ---------------------------------------------- */
|
|
49
|
-
function toggleFaq(id: string) {
|
|
50
|
-
const next = new Set(openFaqIds);
|
|
51
|
-
if (next.has(id)) {
|
|
52
|
-
next.delete(id);
|
|
53
|
-
} else {
|
|
54
|
-
next.add(id);
|
|
55
|
-
}
|
|
56
|
-
openFaqIds = next;
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
onMount(() => {
|
|
60
|
-
const load = async () => {
|
|
61
|
-
isLoading = true;
|
|
62
|
-
error = "";
|
|
63
|
-
try {
|
|
64
|
-
const data = await fetchSupportInit();
|
|
65
|
-
templates = data.templates || [];
|
|
66
|
-
articles = data.articles || [];
|
|
67
|
-
faqs = data.faqs || [];
|
|
68
|
-
websiteName = data.website?.name || "Support";
|
|
69
|
-
} catch (loadError) {
|
|
70
|
-
error = loadError instanceof Error ? loadError.message : "Unexpected error.";
|
|
71
|
-
} finally {
|
|
72
|
-
isLoading = false;
|
|
73
|
-
}
|
|
74
|
-
};
|
|
75
|
-
|
|
76
|
-
void load();
|
|
77
|
-
});
|
|
4
|
+
let { data } = $props();
|
|
78
5
|
</script>
|
|
79
6
|
|
|
80
|
-
|
|
81
|
-
<!-- Loading skeleton -->
|
|
82
|
-
<!-- --------------------------------------- -->
|
|
83
|
-
{#if isLoading}
|
|
84
|
-
<div class="vk-shell">
|
|
85
|
-
<div class="vk-page vk-animate-in">
|
|
86
|
-
<div class="vk-hero-row">
|
|
87
|
-
<div>
|
|
88
|
-
<div class="vk-skeleton" aria-hidden="true">
|
|
89
|
-
|
|
90
|
-
</div>
|
|
91
|
-
<div class="vk-skeleton" aria-hidden="true">
|
|
92
|
-
|
|
93
|
-
</div>
|
|
94
|
-
</div>
|
|
95
|
-
<div class="vk-skeleton" aria-hidden="true">
|
|
96
|
-
|
|
97
|
-
</div>
|
|
98
|
-
</div>
|
|
99
|
-
<div class="vk-search-wrap">
|
|
100
|
-
<div class="vk-skeleton" aria-hidden="true"> </div>
|
|
101
|
-
</div>
|
|
102
|
-
<div class="vk-content-grid">
|
|
103
|
-
<div>
|
|
104
|
-
<div class="vk-skeleton" aria-hidden="true"> </div>
|
|
105
|
-
<div class="vk-skeleton" aria-hidden="true"> </div>
|
|
106
|
-
<div class="vk-skeleton" aria-hidden="true"> </div>
|
|
107
|
-
</div>
|
|
108
|
-
<div>
|
|
109
|
-
<div class="vk-skeleton" aria-hidden="true"> </div>
|
|
110
|
-
<div class="vk-skeleton" aria-hidden="true"> </div>
|
|
111
|
-
<div class="vk-skeleton" aria-hidden="true"> </div>
|
|
112
|
-
</div>
|
|
113
|
-
</div>
|
|
114
|
-
</div>
|
|
115
|
-
</div>
|
|
116
|
-
|
|
117
|
-
<!-- --------------------------------------- -->
|
|
118
|
-
<!-- Article viewer -->
|
|
119
|
-
<!-- --------------------------------------- -->
|
|
120
|
-
{:else if selectedArticle}
|
|
121
|
-
<div class="vk-shell">
|
|
122
|
-
<div class="vk-page vk-animate-in">
|
|
123
|
-
<!-- Hero stays visible -->
|
|
124
|
-
<div class="vk-hero-row">
|
|
125
|
-
<div>
|
|
126
|
-
<h1 class="vk-hero-title">{websiteName}</h1>
|
|
127
|
-
<p class="vk-hero-subtitle">How can we help you today?</p>
|
|
128
|
-
</div>
|
|
129
|
-
{#if templates.length > 0}
|
|
130
|
-
<button
|
|
131
|
-
type="button"
|
|
132
|
-
class="vk-button primary pill"
|
|
133
|
-
onclick={() => (dialogOpen = true)}
|
|
134
|
-
>
|
|
135
|
-
💬 Contact Support
|
|
136
|
-
</button>
|
|
137
|
-
{/if}
|
|
138
|
-
</div>
|
|
139
|
-
|
|
140
|
-
<!-- Article content -->
|
|
141
|
-
<div class="vk-article-viewer">
|
|
142
|
-
<button
|
|
143
|
-
type="button"
|
|
144
|
-
onclick={() => (selectedArticle = null)}
|
|
145
|
-
class="vk-back-button"
|
|
146
|
-
>
|
|
147
|
-
← Back to articles
|
|
148
|
-
</button>
|
|
149
|
-
|
|
150
|
-
<div class="vk-article-viewer-card">
|
|
151
|
-
<h2 class="vk-article-viewer-title">{selectedArticle.title}</h2>
|
|
152
|
-
<div class="vk-article-viewer-content vk-message-html">
|
|
153
|
-
{@html sanitizeHtml(selectedArticle.content)}
|
|
154
|
-
</div>
|
|
155
|
-
</div>
|
|
156
|
-
</div>
|
|
157
|
-
</div>
|
|
158
|
-
|
|
159
|
-
<!-- Ticket dialog (also accessible from article view) -->
|
|
160
|
-
<TicketDialog open={dialogOpen} onclose={() => (dialogOpen = false)} templates={templates} />
|
|
161
|
-
</div>
|
|
162
|
-
|
|
163
|
-
<!-- --------------------------------------- -->
|
|
164
|
-
<!-- Home view -->
|
|
165
|
-
<!-- --------------------------------------- -->
|
|
166
|
-
{:else}
|
|
167
|
-
<div class="vk-shell">
|
|
168
|
-
<div class="vk-page vk-animate-in">
|
|
169
|
-
<!-- Row 1 - Hero -->
|
|
170
|
-
<div class="vk-hero-row">
|
|
171
|
-
<div>
|
|
172
|
-
<h1 class="vk-hero-title">{websiteName}</h1>
|
|
173
|
-
<p class="vk-hero-subtitle">How can we help you today?</p>
|
|
174
|
-
</div>
|
|
175
|
-
{#if templates.length > 0}
|
|
176
|
-
<div class="vk-hero-cta">
|
|
177
|
-
<span class="vk-hero-cta-hint">
|
|
178
|
-
Can't find what you're looking for?
|
|
179
|
-
</span>
|
|
180
|
-
<button
|
|
181
|
-
type="button"
|
|
182
|
-
class="vk-button primary pill"
|
|
183
|
-
onclick={() => (dialogOpen = true)}
|
|
184
|
-
>
|
|
185
|
-
💬 Contact Support
|
|
186
|
-
</button>
|
|
187
|
-
</div>
|
|
188
|
-
{/if}
|
|
189
|
-
</div>
|
|
190
|
-
|
|
191
|
-
<!-- Alerts -->
|
|
192
|
-
{#if error}
|
|
193
|
-
<div class={cn("vk-alert vk-slide-up", "error")} role="alert">
|
|
194
|
-
<span>⚠</span>
|
|
195
|
-
<span>{error}</span>
|
|
196
|
-
<button
|
|
197
|
-
type="button"
|
|
198
|
-
onclick={() => (error = "")}
|
|
199
|
-
class="vk-alert-dismiss"
|
|
200
|
-
aria-label="Dismiss"
|
|
201
|
-
>
|
|
202
|
-
✕
|
|
203
|
-
</button>
|
|
204
|
-
</div>
|
|
205
|
-
{/if}
|
|
206
|
-
|
|
207
|
-
<!-- Row 2 - Search bar -->
|
|
208
|
-
{#if hasContent}
|
|
209
|
-
<div 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
|
-
bind:value={searchQuery}
|
|
216
|
-
/>
|
|
217
|
-
</div>
|
|
218
|
-
{/if}
|
|
219
|
-
|
|
220
|
-
<!-- Row 3 - Split content -->
|
|
221
|
-
{#if hasResults}
|
|
222
|
-
<div class="vk-content-grid">
|
|
223
|
-
<!-- Left: Articles -->
|
|
224
|
-
{#if filteredArticles.length > 0}
|
|
225
|
-
<div>
|
|
226
|
-
<div class="vk-section-title-row">
|
|
227
|
-
<span class="vk-section-title-icon">📄</span>
|
|
228
|
-
<h2 class="vk-section-title">Popular Articles</h2>
|
|
229
|
-
</div>
|
|
230
|
-
<div class="vk-article-list">
|
|
231
|
-
{#each filteredArticles as article (article.id)}
|
|
232
|
-
<button
|
|
233
|
-
type="button"
|
|
234
|
-
class="vk-article-card"
|
|
235
|
-
onclick={() => (selectedArticle = article)}
|
|
236
|
-
>
|
|
237
|
-
<div class="vk-article-icon" aria-hidden="true">
|
|
238
|
-
📄
|
|
239
|
-
</div>
|
|
240
|
-
<div>
|
|
241
|
-
<h3 class="vk-article-title">{article.title}</h3>
|
|
242
|
-
{#if article.content}
|
|
243
|
-
<p class="vk-article-preview">
|
|
244
|
-
{stripHtml(article.content).substring(0, 150)}
|
|
245
|
-
</p>
|
|
246
|
-
{/if}
|
|
247
|
-
</div>
|
|
248
|
-
</button>
|
|
249
|
-
{/each}
|
|
250
|
-
</div>
|
|
251
|
-
</div>
|
|
252
|
-
{/if}
|
|
253
|
-
|
|
254
|
-
<!-- Right: FAQs -->
|
|
255
|
-
{#if filteredFaqs.length > 0}
|
|
256
|
-
<div>
|
|
257
|
-
<div class="vk-section-title-row">
|
|
258
|
-
<span class="vk-section-title-icon">❔</span>
|
|
259
|
-
<h2 class="vk-section-title">Frequently Asked Questions</h2>
|
|
260
|
-
</div>
|
|
261
|
-
<div class="vk-faq-list">
|
|
262
|
-
{#each filteredFaqs as faq (faq.id)}
|
|
263
|
-
<div class="vk-faq-item">
|
|
264
|
-
<button
|
|
265
|
-
type="button"
|
|
266
|
-
onclick={() => toggleFaq(faq.id)}
|
|
267
|
-
class="vk-faq-question"
|
|
268
|
-
aria-expanded={openFaqIds.has(faq.id)}
|
|
269
|
-
>
|
|
270
|
-
{faq.question}
|
|
271
|
-
<span class={cn("vk-faq-chevron", openFaqIds.has(faq.id) && "open")}>▼</span>
|
|
272
|
-
</button>
|
|
273
|
-
<div class={cn("vk-faq-body", openFaqIds.has(faq.id) && "open")}>
|
|
274
|
-
<div>
|
|
275
|
-
<div class="vk-faq-answer">{faq.answer}</div>
|
|
276
|
-
</div>
|
|
277
|
-
</div>
|
|
278
|
-
</div>
|
|
279
|
-
{/each}
|
|
280
|
-
</div>
|
|
281
|
-
</div>
|
|
282
|
-
{/if}
|
|
283
|
-
</div>
|
|
284
|
-
{/if}
|
|
285
|
-
|
|
286
|
-
<!-- No search results -->
|
|
287
|
-
{#if searchQuery.trim() && !hasResults}
|
|
288
|
-
<div class="vk-no-results">
|
|
289
|
-
No results found for “{searchQuery}”
|
|
290
|
-
</div>
|
|
291
|
-
{/if}
|
|
292
|
-
|
|
293
|
-
<!-- Empty state - no articles and no FAQs at all -->
|
|
294
|
-
{#if !hasContent && !error}
|
|
295
|
-
<div class="vk-cta-card">
|
|
296
|
-
<div class="vk-cta-icon">💬</div>
|
|
297
|
-
<p class="vk-cta-text">Need help? Our team is here for you.</p>
|
|
298
|
-
<button
|
|
299
|
-
type="button"
|
|
300
|
-
class="vk-button primary pill"
|
|
301
|
-
onclick={() => (dialogOpen = true)}
|
|
302
|
-
>
|
|
303
|
-
Contact Support
|
|
304
|
-
</button>
|
|
305
|
-
</div>
|
|
306
|
-
{/if}
|
|
307
|
-
</div>
|
|
308
|
-
|
|
309
|
-
<!-- Ticket dialog -->
|
|
310
|
-
<TicketDialog open={dialogOpen} onclose={() => (dialogOpen = false)} templates={templates} />
|
|
311
|
-
</div>
|
|
312
|
-
{/if}
|
|
313
|
-
|
|
314
|
-
<style>
|
|
315
|
-
@import '$lib/vicket.css';
|
|
316
|
-
</style>
|
|
7
|
+
<SupportContent initData={data.initData} initError={data.initError} />
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { VICKET_API_URL, VICKET_API_KEY } from '$env/static/private';
|
|
2
|
+
import { createServerClient } from 'vicket/server';
|
|
3
|
+
import type { PageServerLoad } from './$types';
|
|
4
|
+
|
|
5
|
+
export const load: PageServerLoad = async ({ url }) => {
|
|
6
|
+
const token = url.searchParams.get('token') || '';
|
|
7
|
+
|
|
8
|
+
if (!token.trim()) {
|
|
9
|
+
return { thread: null, fetchError: 'Missing ticket token in URL.', token };
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
try {
|
|
13
|
+
const vicket = createServerClient({ apiUrl: VICKET_API_URL, apiKey: VICKET_API_KEY });
|
|
14
|
+
const thread = await vicket.fetchThread(token);
|
|
15
|
+
return { thread, fetchError: '', token };
|
|
16
|
+
} catch (e) {
|
|
17
|
+
return { thread: null, fetchError: e instanceof Error ? e.message : 'Failed to load ticket.', token };
|
|
18
|
+
}
|
|
19
|
+
};
|
|
@@ -1,117 +1,36 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
|
-
import {
|
|
3
|
-
import
|
|
4
|
-
import type { TicketThread } from "$lib/vicket/types";
|
|
5
|
-
import { cn, sanitizeHtml, stripHtml, formatDate, isFileAnswer, formatAnswerText } from "$lib/vicket/utils";
|
|
6
|
-
import { AUTHOR_LABELS, fetchTicketThread, sendReply } from "$lib/vicket/api";
|
|
2
|
+
import { cn, sanitizeHtml, stripHtml, formatDate, isFileAnswer, formatAnswerText, AUTHOR_LABELS, type TicketThread, type Message, type TicketAnswer } from "vicket";
|
|
3
|
+
import ReplyForm from "$lib/vicket/ReplyForm.svelte";
|
|
7
4
|
|
|
8
|
-
|
|
9
|
-
/* State */
|
|
10
|
-
/* ---------------------------------------------- */
|
|
11
|
-
let thread = $state<TicketThread | null>(null);
|
|
12
|
-
let content = $state("");
|
|
13
|
-
let files = $state<File[]>([]);
|
|
14
|
-
let isLoading = $state(true);
|
|
15
|
-
let isSending = $state(false);
|
|
16
|
-
let error = $state("");
|
|
17
|
-
let success = $state("");
|
|
5
|
+
let { data } = $props();
|
|
18
6
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
let token = $derived(page.url.searchParams.get("token") || "");
|
|
23
|
-
let hasToken = $derived(token.trim().length > 0);
|
|
7
|
+
let thread = $derived<TicketThread | null>(data.thread || null);
|
|
8
|
+
let error = $state(data.fetchError || "");
|
|
9
|
+
let token = $derived(data.token);
|
|
24
10
|
|
|
25
11
|
let firstReporterMessage = $derived.by(() => {
|
|
26
12
|
if (!thread?.messages || thread.messages.length === 0) return null;
|
|
27
13
|
const sorted = [...thread.messages].sort(
|
|
28
|
-
(a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime(),
|
|
14
|
+
(a: Message, b: Message) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime(),
|
|
29
15
|
);
|
|
30
|
-
// Only treat as description if the very first message is from the reporter
|
|
31
16
|
return sorted[0].author_type === "reporter" ? sorted[0] : null;
|
|
32
17
|
});
|
|
33
18
|
|
|
34
19
|
let sortedMessages = $derived.by(() => {
|
|
35
20
|
if (!thread?.messages) return [];
|
|
36
21
|
return [...thread.messages]
|
|
37
|
-
.sort((a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime())
|
|
38
|
-
.filter((m) => !firstReporterMessage || m.id !== firstReporterMessage.id);
|
|
22
|
+
.sort((a: Message, b: Message) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime())
|
|
23
|
+
.filter((m: Message) => !firstReporterMessage || m.id !== firstReporterMessage.id);
|
|
39
24
|
});
|
|
40
25
|
|
|
41
26
|
let summaryAnswers = $derived.by(() => {
|
|
42
27
|
if (!thread?.answers) return [];
|
|
43
|
-
return thread.answers.filter((answer) => {
|
|
28
|
+
return thread.answers.filter((answer: TicketAnswer) => {
|
|
44
29
|
if (answer.attachments && answer.attachments.length > 0) return true;
|
|
45
30
|
if (answer.answer && answer.answer.trim().length > 0) return true;
|
|
46
31
|
return false;
|
|
47
32
|
});
|
|
48
33
|
});
|
|
49
|
-
|
|
50
|
-
/* ---------------------------------------------- */
|
|
51
|
-
/* Functions */
|
|
52
|
-
/* ---------------------------------------------- */
|
|
53
|
-
function removeFile(index: number) {
|
|
54
|
-
files = files.filter((_, i) => i !== index);
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
function onFileChange(event: Event) {
|
|
58
|
-
const input = event.target as HTMLInputElement;
|
|
59
|
-
const newFiles = Array.from(input.files || []);
|
|
60
|
-
files = [...files, ...newFiles];
|
|
61
|
-
input.value = "";
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
async function loadThread() {
|
|
65
|
-
if (!hasToken) {
|
|
66
|
-
isLoading = false;
|
|
67
|
-
error = "Missing ticket token in URL.";
|
|
68
|
-
return;
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
isLoading = true;
|
|
72
|
-
error = "";
|
|
73
|
-
try {
|
|
74
|
-
const data = await fetchTicketThread(token);
|
|
75
|
-
thread = data;
|
|
76
|
-
} catch (loadError) {
|
|
77
|
-
error = loadError instanceof Error ? loadError.message : "Unexpected error.";
|
|
78
|
-
} finally {
|
|
79
|
-
isLoading = false;
|
|
80
|
-
}
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
async function onSubmitReply(event: SubmitEvent) {
|
|
84
|
-
event.preventDefault();
|
|
85
|
-
error = "";
|
|
86
|
-
success = "";
|
|
87
|
-
|
|
88
|
-
if (!content.trim() && files.length === 0) {
|
|
89
|
-
error = "Reply content is required.";
|
|
90
|
-
return;
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
if (!hasToken) {
|
|
94
|
-
error = "Missing ticket token.";
|
|
95
|
-
return;
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
isSending = true;
|
|
99
|
-
try {
|
|
100
|
-
await sendReply(token, content.trim(), files);
|
|
101
|
-
content = "";
|
|
102
|
-
files = [];
|
|
103
|
-
success = "Reply sent.";
|
|
104
|
-
await loadThread();
|
|
105
|
-
} catch (replyError) {
|
|
106
|
-
error = replyError instanceof Error ? replyError.message : "Unexpected error.";
|
|
107
|
-
} finally {
|
|
108
|
-
isSending = false;
|
|
109
|
-
}
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
onMount(() => {
|
|
113
|
-
void loadThread();
|
|
114
|
-
});
|
|
115
34
|
</script>
|
|
116
35
|
|
|
117
36
|
<div class="vk-shell">
|
|
@@ -138,54 +57,9 @@
|
|
|
138
57
|
</button>
|
|
139
58
|
</div>
|
|
140
59
|
{/if}
|
|
141
|
-
{#if success}
|
|
142
|
-
<div class={cn("vk-alert vk-slide-up", "success")} role="alert">
|
|
143
|
-
<span>✓</span>
|
|
144
|
-
<span>{success}</span>
|
|
145
|
-
<button
|
|
146
|
-
type="button"
|
|
147
|
-
onclick={() => (success = "")}
|
|
148
|
-
class="vk-alert-dismiss"
|
|
149
|
-
aria-label="Dismiss"
|
|
150
|
-
>
|
|
151
|
-
✕
|
|
152
|
-
</button>
|
|
153
|
-
</div>
|
|
154
|
-
{/if}
|
|
155
|
-
|
|
156
|
-
<!-- Loading skeleton -->
|
|
157
|
-
{#if isLoading}
|
|
158
|
-
<div class="vk-stack vk-animate-in">
|
|
159
|
-
<!-- Header skeleton -->
|
|
160
|
-
<div>
|
|
161
|
-
<div class="vk-skeleton" aria-hidden="true">
|
|
162
|
-
|
|
163
|
-
</div>
|
|
164
|
-
<div class="vk-compose-files">
|
|
165
|
-
<span class="vk-skeleton" aria-hidden="true"> </span>
|
|
166
|
-
<span class="vk-skeleton" aria-hidden="true"> </span>
|
|
167
|
-
</div>
|
|
168
|
-
</div>
|
|
169
|
-
<!-- Messages skeleton -->
|
|
170
|
-
<div class="vk-section-card">
|
|
171
|
-
<div class="vk-section-header">
|
|
172
|
-
<span class="vk-skeleton" aria-hidden="true"> </span>
|
|
173
|
-
</div>
|
|
174
|
-
{#each [1, 2, 3] as i (i)}
|
|
175
|
-
<div class="vk-message">
|
|
176
|
-
<div class="vk-avatar vk-skeleton" aria-hidden="true"> </div>
|
|
177
|
-
<div class="vk-message-content">
|
|
178
|
-
<div class="vk-skeleton" aria-hidden="true"> </div>
|
|
179
|
-
<div class="vk-skeleton" aria-hidden="true"> </div>
|
|
180
|
-
</div>
|
|
181
|
-
</div>
|
|
182
|
-
{/each}
|
|
183
|
-
</div>
|
|
184
|
-
</div>
|
|
185
|
-
{/if}
|
|
186
60
|
|
|
187
61
|
<!-- Thread -->
|
|
188
|
-
{#if
|
|
62
|
+
{#if thread}
|
|
189
63
|
<div class="vk-stack">
|
|
190
64
|
<!-- Header -->
|
|
191
65
|
<div>
|
|
@@ -286,57 +160,8 @@
|
|
|
286
160
|
<h2>Comments</h2>
|
|
287
161
|
</div>
|
|
288
162
|
|
|
289
|
-
<!--
|
|
290
|
-
<
|
|
291
|
-
<form class="vk-stack" onsubmit={onSubmitReply}>
|
|
292
|
-
<textarea
|
|
293
|
-
class="vk-textarea"
|
|
294
|
-
bind:value={content}
|
|
295
|
-
placeholder="Write your reply..."
|
|
296
|
-
></textarea>
|
|
297
|
-
|
|
298
|
-
<div class="vk-compose-row">
|
|
299
|
-
<!-- File input -->
|
|
300
|
-
<div class="vk-compose-files">
|
|
301
|
-
<label class="vk-browse-btn">
|
|
302
|
-
📎 Browse files
|
|
303
|
-
<input
|
|
304
|
-
type="file"
|
|
305
|
-
multiple
|
|
306
|
-
onchange={onFileChange}
|
|
307
|
-
/>
|
|
308
|
-
</label>
|
|
309
|
-
{#each files as file, i}
|
|
310
|
-
<span class="vk-file-chip">
|
|
311
|
-
📎
|
|
312
|
-
<span class="vk-file-chip-name">{file.name}</span>
|
|
313
|
-
<button
|
|
314
|
-
type="button"
|
|
315
|
-
onclick={() => removeFile(i)}
|
|
316
|
-
aria-label="Remove {file.name}"
|
|
317
|
-
>
|
|
318
|
-
✕
|
|
319
|
-
</button>
|
|
320
|
-
</span>
|
|
321
|
-
{/each}
|
|
322
|
-
</div>
|
|
323
|
-
|
|
324
|
-
<!-- Send -->
|
|
325
|
-
<button
|
|
326
|
-
class="vk-button primary"
|
|
327
|
-
disabled={isSending}
|
|
328
|
-
type="submit"
|
|
329
|
-
>
|
|
330
|
-
{#if isSending}
|
|
331
|
-
<span class="vk-spinner"></span>
|
|
332
|
-
Sending...
|
|
333
|
-
{:else}
|
|
334
|
-
Send
|
|
335
|
-
{/if}
|
|
336
|
-
</button>
|
|
337
|
-
</div>
|
|
338
|
-
</form>
|
|
339
|
-
</div>
|
|
163
|
+
<!-- Interactive reply form -->
|
|
164
|
+
<ReplyForm {token} />
|
|
340
165
|
|
|
341
166
|
<!-- Message list -->
|
|
342
167
|
{#if sortedMessages.length === 0}
|