@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
|
@@ -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,5 +1,7 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
|
-
import
|
|
2
|
+
import SupportContent from "$lib/vicket/SupportContent.svelte";
|
|
3
|
+
|
|
4
|
+
let { data } = $props();
|
|
3
5
|
</script>
|
|
4
6
|
|
|
5
|
-
<
|
|
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,5 +1,295 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
|
-
import
|
|
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";
|
|
4
|
+
|
|
5
|
+
let { data } = $props();
|
|
6
|
+
|
|
7
|
+
let thread = $derived<TicketThread | null>(data.thread || null);
|
|
8
|
+
let error = $state(data.fetchError || "");
|
|
9
|
+
let token = $derived(data.token);
|
|
10
|
+
|
|
11
|
+
let firstReporterMessage = $derived.by(() => {
|
|
12
|
+
if (!thread?.messages || thread.messages.length === 0) return null;
|
|
13
|
+
const sorted = [...thread.messages].sort(
|
|
14
|
+
(a: Message, b: Message) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime(),
|
|
15
|
+
);
|
|
16
|
+
return sorted[0].author_type === "reporter" ? sorted[0] : null;
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
let sortedMessages = $derived.by(() => {
|
|
20
|
+
if (!thread?.messages) return [];
|
|
21
|
+
return [...thread.messages]
|
|
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);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
let summaryAnswers = $derived.by(() => {
|
|
27
|
+
if (!thread?.answers) return [];
|
|
28
|
+
return thread.answers.filter((answer: TicketAnswer) => {
|
|
29
|
+
if (answer.attachments && answer.attachments.length > 0) return true;
|
|
30
|
+
if (answer.answer && answer.answer.trim().length > 0) return true;
|
|
31
|
+
return false;
|
|
32
|
+
});
|
|
33
|
+
});
|
|
3
34
|
</script>
|
|
4
35
|
|
|
5
|
-
<
|
|
36
|
+
<div class="min-h-screen bg-slate-50 font-[system-ui,-apple-system,sans-serif] antialiased">
|
|
37
|
+
<div class="mx-auto max-w-4xl px-6 py-16">
|
|
38
|
+
<!-- Back link -->
|
|
39
|
+
<div class="mb-6">
|
|
40
|
+
<a href="/support" class="inline-flex items-center gap-1.5 text-sm font-medium text-slate-500 no-underline transition-colors hover:text-slate-900">
|
|
41
|
+
<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>
|
|
42
|
+
Back to support
|
|
43
|
+
</a>
|
|
44
|
+
</div>
|
|
45
|
+
|
|
46
|
+
<!-- Error alert -->
|
|
47
|
+
{#if error}
|
|
48
|
+
<div class="mb-6 flex items-start gap-3 rounded-xl border border-red-200 bg-red-50 p-4 text-sm text-red-900" role="alert">
|
|
49
|
+
<span class="mt-0.5 shrink-0">
|
|
50
|
+
<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>
|
|
51
|
+
</span>
|
|
52
|
+
<span class="flex-1">{error}</span>
|
|
53
|
+
<button type="button" onclick={() => (error = "")} class="shrink-0 cursor-pointer border-none bg-transparent p-0 opacity-50 transition-opacity hover:opacity-100" aria-label="Dismiss">
|
|
54
|
+
<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>
|
|
55
|
+
</button>
|
|
56
|
+
</div>
|
|
57
|
+
{/if}
|
|
58
|
+
|
|
59
|
+
<!-- Thread -->
|
|
60
|
+
{#if thread}
|
|
61
|
+
<div class="space-y-6">
|
|
62
|
+
<!-- Header -->
|
|
63
|
+
<div>
|
|
64
|
+
<div class="flex items-start gap-3">
|
|
65
|
+
<h1 class="m-0 flex-1 text-xl font-bold text-slate-900 md:text-2xl">{thread.title}</h1>
|
|
66
|
+
{#if thread.id}
|
|
67
|
+
<span class="shrink-0 rounded-full bg-slate-100 px-2.5 py-1 text-xs font-medium text-slate-500">
|
|
68
|
+
#{thread.id.slice(0, 8)}
|
|
69
|
+
</span>
|
|
70
|
+
{/if}
|
|
71
|
+
</div>
|
|
72
|
+
{#if (thread.status?.label && thread.status.label.toLowerCase() !== "open") || (thread.priority?.label && thread.priority.label.toLowerCase() !== "low")}
|
|
73
|
+
<div class="mt-3 flex flex-wrap gap-2">
|
|
74
|
+
{#if thread.status?.label && thread.status.label.toLowerCase() !== "open"}
|
|
75
|
+
<span class="rounded-full bg-blue-50 px-3 py-1 text-xs font-semibold text-blue-700">
|
|
76
|
+
{thread.status.label}
|
|
77
|
+
</span>
|
|
78
|
+
{/if}
|
|
79
|
+
{#if thread.priority?.label && thread.priority.label.toLowerCase() !== "low"}
|
|
80
|
+
<span class="rounded-full bg-amber-50 px-3 py-1 text-xs font-semibold text-amber-700">
|
|
81
|
+
{thread.priority.label}
|
|
82
|
+
</span>
|
|
83
|
+
{/if}
|
|
84
|
+
</div>
|
|
85
|
+
{/if}
|
|
86
|
+
</div>
|
|
87
|
+
|
|
88
|
+
<!-- Summary (ticket form answers) -->
|
|
89
|
+
{#if summaryAnswers.length > 0}
|
|
90
|
+
<div class="rounded-2xl border border-slate-200 bg-white shadow-sm">
|
|
91
|
+
<div class="flex items-center gap-2 border-b border-slate-100 px-5 py-3">
|
|
92
|
+
<span class="text-slate-400">
|
|
93
|
+
<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>
|
|
94
|
+
</span>
|
|
95
|
+
<h2 class="m-0 text-sm font-semibold text-slate-900">Summary</h2>
|
|
96
|
+
</div>
|
|
97
|
+
<div class="divide-y divide-slate-50 px-5">
|
|
98
|
+
{#each summaryAnswers as answer (answer.id)}
|
|
99
|
+
<div class="py-3">
|
|
100
|
+
<p class="m-0 text-xs font-semibold uppercase tracking-wider text-slate-400">{answer.question_label || "Question"}</p>
|
|
101
|
+
{#if answer.attachments && answer.attachments.length > 0}
|
|
102
|
+
<div class="mt-1.5 flex flex-wrap gap-1.5">
|
|
103
|
+
{#each answer.attachments as attachment (attachment.id)}
|
|
104
|
+
<a
|
|
105
|
+
class="inline-flex items-center gap-1.5 rounded-lg border border-slate-200 bg-slate-50 px-3 py-1.5 text-xs text-slate-600 no-underline transition-colors hover:bg-slate-100"
|
|
106
|
+
href={attachment.url}
|
|
107
|
+
rel="noopener noreferrer"
|
|
108
|
+
target="_blank"
|
|
109
|
+
>
|
|
110
|
+
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="m21.44 11.05-9.19 9.19a6 6 0 0 1-8.49-8.49l8.57-8.57A4 4 0 1 1 18 8.84l-8.59 8.57a2 2 0 0 1-2.83-2.83l8.49-8.48" /></svg>
|
|
111
|
+
{attachment.original_filename}
|
|
112
|
+
</a>
|
|
113
|
+
{/each}
|
|
114
|
+
</div>
|
|
115
|
+
{:else if isFileAnswer(answer.answer)}
|
|
116
|
+
<p class="m-0 mt-1 text-sm italic text-slate-400">File uploaded</p>
|
|
117
|
+
{:else}
|
|
118
|
+
<p class="m-0 mt-1 text-sm text-slate-700">{formatAnswerText(answer.answer) || "-"}</p>
|
|
119
|
+
{/if}
|
|
120
|
+
</div>
|
|
121
|
+
{/each}
|
|
122
|
+
</div>
|
|
123
|
+
</div>
|
|
124
|
+
{/if}
|
|
125
|
+
|
|
126
|
+
<!-- Description (first reporter message) -->
|
|
127
|
+
{#if firstReporterMessage}
|
|
128
|
+
<div class="rounded-2xl border border-slate-200 bg-white shadow-sm">
|
|
129
|
+
<div class="flex items-center gap-2 border-b border-slate-100 px-5 py-3">
|
|
130
|
+
<span class="text-slate-400">
|
|
131
|
+
<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>
|
|
132
|
+
</span>
|
|
133
|
+
<h2 class="m-0 text-sm font-semibold text-slate-900">Description</h2>
|
|
134
|
+
</div>
|
|
135
|
+
<div class="px-5 py-4">
|
|
136
|
+
<div class="vk-message-content text-sm leading-relaxed text-slate-700">
|
|
137
|
+
{@html sanitizeHtml(firstReporterMessage.content)}
|
|
138
|
+
</div>
|
|
139
|
+
{#if firstReporterMessage.attachments && firstReporterMessage.attachments.length > 0}
|
|
140
|
+
<div class="mt-3 flex flex-wrap gap-1.5">
|
|
141
|
+
{#each firstReporterMessage.attachments as att (att.id)}
|
|
142
|
+
<a
|
|
143
|
+
href={att.url}
|
|
144
|
+
target="_blank"
|
|
145
|
+
rel="noopener noreferrer"
|
|
146
|
+
class="inline-flex items-center gap-1.5 rounded-lg border border-slate-200 bg-slate-50 px-3 py-1.5 text-xs text-slate-600 no-underline transition-colors hover:bg-slate-100"
|
|
147
|
+
>
|
|
148
|
+
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="m21.44 11.05-9.19 9.19a6 6 0 0 1-8.49-8.49l8.57-8.57A4 4 0 1 1 18 8.84l-8.59 8.57a2 2 0 0 1-2.83-2.83l8.49-8.48" /></svg>
|
|
149
|
+
{att.original_filename}
|
|
150
|
+
</a>
|
|
151
|
+
{/each}
|
|
152
|
+
</div>
|
|
153
|
+
{/if}
|
|
154
|
+
</div>
|
|
155
|
+
</div>
|
|
156
|
+
{/if}
|
|
157
|
+
|
|
158
|
+
<!-- Comments card -->
|
|
159
|
+
<div class="rounded-2xl border border-slate-200 bg-white shadow-sm">
|
|
160
|
+
<div class="flex items-center gap-2 border-b border-slate-100 px-5 py-3">
|
|
161
|
+
<span class="text-slate-400">
|
|
162
|
+
<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>
|
|
163
|
+
</span>
|
|
164
|
+
<h2 class="m-0 text-sm font-semibold text-slate-900">Comments</h2>
|
|
165
|
+
</div>
|
|
166
|
+
|
|
167
|
+
<!-- Interactive reply form -->
|
|
168
|
+
<ReplyForm {token} />
|
|
169
|
+
|
|
170
|
+
<!-- Message list -->
|
|
171
|
+
{#if sortedMessages.length === 0}
|
|
172
|
+
<div class="px-5 py-10 text-center">
|
|
173
|
+
<div class="mx-auto mb-3 flex h-12 w-12 items-center justify-center rounded-full bg-slate-100 text-slate-400">
|
|
174
|
+
<svg width="20" height="20" 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>
|
|
175
|
+
</div>
|
|
176
|
+
<p class="m-0 text-sm text-slate-500">No messages yet. Be the first to reply!</p>
|
|
177
|
+
</div>
|
|
178
|
+
{:else}
|
|
179
|
+
<div class="divide-y divide-slate-50">
|
|
180
|
+
{#each sortedMessages as message (message.id)}
|
|
181
|
+
{#if message.author_type === "system"}
|
|
182
|
+
<!-- System message -->
|
|
183
|
+
<div class="flex gap-3 px-5 py-4">
|
|
184
|
+
<div class="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-slate-100 text-xs font-bold text-slate-500" aria-hidden="true">
|
|
185
|
+
S
|
|
186
|
+
</div>
|
|
187
|
+
<div class="min-w-0 flex-1">
|
|
188
|
+
<div class="flex items-center gap-2">
|
|
189
|
+
<span class="text-xs font-semibold italic text-slate-400">System</span>
|
|
190
|
+
<span class="text-xs text-slate-400">{formatDate(message.created_at)}</span>
|
|
191
|
+
</div>
|
|
192
|
+
<p class="m-0 mt-1 text-sm italic text-slate-500">
|
|
193
|
+
{stripHtml(message.content)}
|
|
194
|
+
</p>
|
|
195
|
+
</div>
|
|
196
|
+
</div>
|
|
197
|
+
{:else}
|
|
198
|
+
<!-- Regular message -->
|
|
199
|
+
<div class="flex gap-3 px-5 py-4">
|
|
200
|
+
<div class={cn(
|
|
201
|
+
"flex h-8 w-8 shrink-0 items-center justify-center rounded-full text-xs font-bold",
|
|
202
|
+
message.author_type === "reporter" ? "bg-blue-100 text-blue-700" : "bg-green-100 text-green-700"
|
|
203
|
+
)} aria-hidden="true">
|
|
204
|
+
{(AUTHOR_LABELS[message.author_type] || "?")[0]}
|
|
205
|
+
</div>
|
|
206
|
+
<div class="min-w-0 flex-1">
|
|
207
|
+
<div class="flex items-center gap-2">
|
|
208
|
+
<span class="text-xs font-semibold text-slate-900">{AUTHOR_LABELS[message.author_type] || message.author_type}</span>
|
|
209
|
+
<span class="text-xs text-slate-400">{formatDate(message.created_at)}</span>
|
|
210
|
+
</div>
|
|
211
|
+
<div class={cn(
|
|
212
|
+
"mt-1.5 rounded-xl px-4 py-3 text-sm leading-relaxed",
|
|
213
|
+
message.author_type === "user" ? "bg-green-50 text-slate-800" : "bg-slate-50 text-slate-800"
|
|
214
|
+
)}>
|
|
215
|
+
<div class="vk-message-content">
|
|
216
|
+
{@html sanitizeHtml(message.content)}
|
|
217
|
+
</div>
|
|
218
|
+
</div>
|
|
219
|
+
{#if message.attachments && message.attachments.length > 0}
|
|
220
|
+
<div class="mt-2 flex flex-wrap gap-1.5">
|
|
221
|
+
{#each message.attachments as attachment (attachment.id)}
|
|
222
|
+
<a
|
|
223
|
+
class="inline-flex items-center gap-1.5 rounded-lg border border-slate-200 bg-slate-50 px-3 py-1.5 text-xs text-slate-600 no-underline transition-colors hover:bg-slate-100"
|
|
224
|
+
href={attachment.url}
|
|
225
|
+
rel="noopener noreferrer"
|
|
226
|
+
target="_blank"
|
|
227
|
+
>
|
|
228
|
+
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="m21.44 11.05-9.19 9.19a6 6 0 0 1-8.49-8.49l8.57-8.57A4 4 0 1 1 18 8.84l-8.59 8.57a2 2 0 0 1-2.83-2.83l8.49-8.48" /></svg>
|
|
229
|
+
{attachment.original_filename}
|
|
230
|
+
</a>
|
|
231
|
+
{/each}
|
|
232
|
+
</div>
|
|
233
|
+
{/if}
|
|
234
|
+
</div>
|
|
235
|
+
</div>
|
|
236
|
+
{/if}
|
|
237
|
+
{/each}
|
|
238
|
+
</div>
|
|
239
|
+
{/if}
|
|
240
|
+
</div>
|
|
241
|
+
</div>
|
|
242
|
+
{/if}
|
|
243
|
+
</div>
|
|
244
|
+
</div>
|
|
245
|
+
|
|
246
|
+
<style>
|
|
247
|
+
:global(.vk-message-content h1,
|
|
248
|
+
.vk-message-content h2,
|
|
249
|
+
.vk-message-content h3,
|
|
250
|
+
.vk-message-content h4,
|
|
251
|
+
.vk-message-content h5,
|
|
252
|
+
.vk-message-content h6) {
|
|
253
|
+
margin: 1em 0 0.5em;
|
|
254
|
+
line-height: 1.4;
|
|
255
|
+
}
|
|
256
|
+
:global(.vk-message-content p) {
|
|
257
|
+
margin: 0.5em 0;
|
|
258
|
+
}
|
|
259
|
+
:global(.vk-message-content ul,
|
|
260
|
+
.vk-message-content ol) {
|
|
261
|
+
margin: 0.5em 0;
|
|
262
|
+
padding-left: 1.5em;
|
|
263
|
+
}
|
|
264
|
+
:global(.vk-message-content a) {
|
|
265
|
+
color: #2563eb;
|
|
266
|
+
text-decoration: underline;
|
|
267
|
+
}
|
|
268
|
+
:global(.vk-message-content img) {
|
|
269
|
+
max-width: 100%;
|
|
270
|
+
border-radius: 0.5rem;
|
|
271
|
+
}
|
|
272
|
+
:global(.vk-message-content pre) {
|
|
273
|
+
background: #f1f5f9;
|
|
274
|
+
padding: 0.75em 1em;
|
|
275
|
+
border-radius: 0.5rem;
|
|
276
|
+
overflow-x: auto;
|
|
277
|
+
font-size: 0.85em;
|
|
278
|
+
}
|
|
279
|
+
:global(.vk-message-content code) {
|
|
280
|
+
background: #f1f5f9;
|
|
281
|
+
padding: 0.15em 0.35em;
|
|
282
|
+
border-radius: 0.25rem;
|
|
283
|
+
font-size: 0.9em;
|
|
284
|
+
}
|
|
285
|
+
:global(.vk-message-content pre code) {
|
|
286
|
+
background: none;
|
|
287
|
+
padding: 0;
|
|
288
|
+
}
|
|
289
|
+
:global(.vk-message-content blockquote) {
|
|
290
|
+
border-left: 3px solid #cbd5e1;
|
|
291
|
+
margin: 0.5em 0;
|
|
292
|
+
padding-left: 1em;
|
|
293
|
+
color: #64748b;
|
|
294
|
+
}
|
|
295
|
+
</style>
|
|
@@ -1,149 +0,0 @@
|
|
|
1
|
-
/* ---------------------------------------------- */
|
|
2
|
-
/* API functions and constants for Vicket pages */
|
|
3
|
-
/* ---------------------------------------------- */
|
|
4
|
-
|
|
5
|
-
import type { FormValues, SupportInitResponse, TicketThread } from "./types";
|
|
6
|
-
|
|
7
|
-
export const PROXY_BASE = "/api/vicket";
|
|
8
|
-
|
|
9
|
-
export const initialFormValues: FormValues = { email: "", title: "", answers: {} };
|
|
10
|
-
|
|
11
|
-
export const AUTHOR_LABELS: Record<string, string> = {
|
|
12
|
-
reporter: "You",
|
|
13
|
-
user: "Support",
|
|
14
|
-
system: "System",
|
|
15
|
-
};
|
|
16
|
-
|
|
17
|
-
export async function fetchSupportInit(): Promise<NonNullable<SupportInitResponse["data"]>> {
|
|
18
|
-
const response = await fetch(`${PROXY_BASE}/init`, {
|
|
19
|
-
method: "GET",
|
|
20
|
-
cache: "no-store",
|
|
21
|
-
headers: { "Content-Type": "application/json" },
|
|
22
|
-
});
|
|
23
|
-
const payload = (await response.json()) as SupportInitResponse;
|
|
24
|
-
|
|
25
|
-
if (!response.ok || !payload?.success || !payload?.data) {
|
|
26
|
-
throw new Error(payload?.error || "Failed to load support data.");
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
return payload.data;
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
export async function createTicket(body: {
|
|
33
|
-
email: string;
|
|
34
|
-
title: string;
|
|
35
|
-
templateId: string;
|
|
36
|
-
answers: Record<string, unknown>;
|
|
37
|
-
hasFiles: boolean;
|
|
38
|
-
fileQuestionIds: string[];
|
|
39
|
-
}): Promise<{ emailLimitReached?: boolean; warning?: string }> {
|
|
40
|
-
const payload = {
|
|
41
|
-
email: body.email,
|
|
42
|
-
title: body.title,
|
|
43
|
-
templateId: body.templateId,
|
|
44
|
-
answers: { ...body.answers },
|
|
45
|
-
};
|
|
46
|
-
|
|
47
|
-
let response: Response;
|
|
48
|
-
if (body.hasFiles) {
|
|
49
|
-
const formData = new FormData();
|
|
50
|
-
const normalizedAnswers: Record<string, unknown> = {};
|
|
51
|
-
for (const [questionId, answer] of Object.entries(payload.answers)) {
|
|
52
|
-
if (Array.isArray(answer) && answer.length > 0 && answer[0] instanceof File) {
|
|
53
|
-
(answer as File[]).forEach((f) => formData.append(`files[${questionId}]`, f));
|
|
54
|
-
normalizedAnswers[questionId] = "__isFile:true";
|
|
55
|
-
} else if (answer instanceof File) {
|
|
56
|
-
formData.append(`files[${questionId}]`, answer);
|
|
57
|
-
normalizedAnswers[questionId] = "__isFile:true";
|
|
58
|
-
} else {
|
|
59
|
-
normalizedAnswers[questionId] = answer;
|
|
60
|
-
}
|
|
61
|
-
}
|
|
62
|
-
formData.append("data", JSON.stringify({ ...payload, answers: normalizedAnswers }));
|
|
63
|
-
response = await fetch(`${PROXY_BASE}/tickets`, {
|
|
64
|
-
method: "POST",
|
|
65
|
-
body: formData,
|
|
66
|
-
});
|
|
67
|
-
} else {
|
|
68
|
-
response = await fetch(`${PROXY_BASE}/tickets`, {
|
|
69
|
-
method: "POST",
|
|
70
|
-
headers: { "Content-Type": "application/json" },
|
|
71
|
-
body: JSON.stringify(payload),
|
|
72
|
-
});
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
const responsePayload = (await response.json()) as {
|
|
76
|
-
error?: string;
|
|
77
|
-
success?: boolean;
|
|
78
|
-
data?: { email_limit_reached?: boolean; warning?: string };
|
|
79
|
-
};
|
|
80
|
-
if (!response.ok || !responsePayload?.success) {
|
|
81
|
-
throw new Error(responsePayload?.error || "Failed to create ticket.");
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
return {
|
|
85
|
-
emailLimitReached: responsePayload.data?.email_limit_reached ?? false,
|
|
86
|
-
warning: responsePayload.data?.warning,
|
|
87
|
-
};
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
export async function fetchTicketThread(token: string): Promise<TicketThread> {
|
|
91
|
-
const response = await fetch(
|
|
92
|
-
`${PROXY_BASE}/ticket?token=${encodeURIComponent(token)}`,
|
|
93
|
-
{
|
|
94
|
-
method: "GET",
|
|
95
|
-
cache: "no-store",
|
|
96
|
-
headers: { "Content-Type": "application/json" },
|
|
97
|
-
},
|
|
98
|
-
);
|
|
99
|
-
const payload = (await response.json()) as {
|
|
100
|
-
success?: boolean;
|
|
101
|
-
error?: string;
|
|
102
|
-
error_code?: string;
|
|
103
|
-
data?: TicketThread;
|
|
104
|
-
};
|
|
105
|
-
|
|
106
|
-
if (!response.ok || !payload?.success || !payload?.data) {
|
|
107
|
-
if (payload?.error_code === "ticket-link-expired") {
|
|
108
|
-
throw new Error("This link has expired. A new secure link has been sent to your email.");
|
|
109
|
-
}
|
|
110
|
-
throw new Error(payload?.error || "Failed to load ticket.");
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
return payload.data;
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
export async function sendReply(token: string, content: string, files: File[]): Promise<void> {
|
|
117
|
-
const url = `${PROXY_BASE}/ticket/messages?token=${encodeURIComponent(token)}`;
|
|
118
|
-
|
|
119
|
-
let response: Response;
|
|
120
|
-
if (files.length > 0) {
|
|
121
|
-
const formData = new FormData();
|
|
122
|
-
formData.append("data", JSON.stringify({ content }));
|
|
123
|
-
for (const file of files) {
|
|
124
|
-
formData.append("files", file);
|
|
125
|
-
}
|
|
126
|
-
response = await fetch(url, {
|
|
127
|
-
method: "POST",
|
|
128
|
-
body: formData,
|
|
129
|
-
});
|
|
130
|
-
} else {
|
|
131
|
-
response = await fetch(url, {
|
|
132
|
-
method: "POST",
|
|
133
|
-
headers: { "Content-Type": "application/json" },
|
|
134
|
-
body: JSON.stringify({ content }),
|
|
135
|
-
});
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
const payload = (await response.json()) as {
|
|
139
|
-
success?: boolean;
|
|
140
|
-
error?: string;
|
|
141
|
-
error_code?: string;
|
|
142
|
-
};
|
|
143
|
-
if (!response.ok || !payload?.success) {
|
|
144
|
-
if (payload?.error_code === "ticket-link-expired") {
|
|
145
|
-
throw new Error("This link has expired. A new secure link has been sent to your email.");
|
|
146
|
-
}
|
|
147
|
-
throw new Error(payload?.error || "Failed to send reply.");
|
|
148
|
-
}
|
|
149
|
-
}
|
|
@@ -1,85 +0,0 @@
|
|
|
1
|
-
/* ---------------------------------------------- */
|
|
2
|
-
/* Shared types for Vicket support pages */
|
|
3
|
-
/* ---------------------------------------------- */
|
|
4
|
-
|
|
5
|
-
export type TemplateOption = {
|
|
6
|
-
id: string;
|
|
7
|
-
label: string;
|
|
8
|
-
value: string;
|
|
9
|
-
};
|
|
10
|
-
|
|
11
|
-
export type TemplateQuestion = {
|
|
12
|
-
id: string;
|
|
13
|
-
label: string;
|
|
14
|
-
type: "TEXT" | "TEXTAREA" | "SELECT" | "CHECKBOX" | "DATE" | "FILE";
|
|
15
|
-
required: boolean;
|
|
16
|
-
order: number;
|
|
17
|
-
options?: TemplateOption[];
|
|
18
|
-
};
|
|
19
|
-
|
|
20
|
-
export type Template = {
|
|
21
|
-
id: string;
|
|
22
|
-
name: string;
|
|
23
|
-
description: string;
|
|
24
|
-
questions: TemplateQuestion[];
|
|
25
|
-
};
|
|
26
|
-
|
|
27
|
-
export type Article = {
|
|
28
|
-
id: string;
|
|
29
|
-
title: string;
|
|
30
|
-
slug: string;
|
|
31
|
-
content: string;
|
|
32
|
-
};
|
|
33
|
-
|
|
34
|
-
export type Faq = {
|
|
35
|
-
id: string;
|
|
36
|
-
question: string;
|
|
37
|
-
answer: string;
|
|
38
|
-
};
|
|
39
|
-
|
|
40
|
-
export type SupportInitResponse = {
|
|
41
|
-
success: boolean;
|
|
42
|
-
data?: {
|
|
43
|
-
website?: { name?: string };
|
|
44
|
-
templates: Template[];
|
|
45
|
-
articles?: Article[];
|
|
46
|
-
faqs?: Faq[];
|
|
47
|
-
};
|
|
48
|
-
error?: string;
|
|
49
|
-
};
|
|
50
|
-
|
|
51
|
-
export type FormValues = {
|
|
52
|
-
email: string;
|
|
53
|
-
title: string;
|
|
54
|
-
answers: Record<string, unknown>;
|
|
55
|
-
};
|
|
56
|
-
|
|
57
|
-
export type Attachment = {
|
|
58
|
-
id: string;
|
|
59
|
-
original_filename: string;
|
|
60
|
-
url: string;
|
|
61
|
-
};
|
|
62
|
-
|
|
63
|
-
export type Message = {
|
|
64
|
-
id: string;
|
|
65
|
-
content: string;
|
|
66
|
-
author_type: "reporter" | "user" | "system";
|
|
67
|
-
created_at: string;
|
|
68
|
-
attachments?: Attachment[];
|
|
69
|
-
};
|
|
70
|
-
|
|
71
|
-
export type TicketAnswer = {
|
|
72
|
-
id: string;
|
|
73
|
-
question_label: string;
|
|
74
|
-
answer: string;
|
|
75
|
-
attachments?: Attachment[];
|
|
76
|
-
};
|
|
77
|
-
|
|
78
|
-
export type TicketThread = {
|
|
79
|
-
id: string;
|
|
80
|
-
title: string;
|
|
81
|
-
status?: { label: string };
|
|
82
|
-
priority?: { label: string };
|
|
83
|
-
messages: Message[];
|
|
84
|
-
answers?: TicketAnswer[];
|
|
85
|
-
};
|
|
@@ -1,49 +0,0 @@
|
|
|
1
|
-
/* ---------------------------------------------- */
|
|
2
|
-
/* Shared utility functions for Vicket pages */
|
|
3
|
-
/* ---------------------------------------------- */
|
|
4
|
-
|
|
5
|
-
export function cn(...classes: (string | false | null | undefined)[]): string {
|
|
6
|
-
return classes.filter(Boolean).join(" ");
|
|
7
|
-
}
|
|
8
|
-
|
|
9
|
-
export function stripHtml(html: string): string {
|
|
10
|
-
return html.replace(/<[^>]*>/g, "");
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
export function sanitizeHtml(html: string): string {
|
|
14
|
-
return html
|
|
15
|
-
.replace(/<script[\s\S]*?<\/script>/gi, "")
|
|
16
|
-
.replace(/\son\w+="[^"]*"/gi, "")
|
|
17
|
-
.replace(/\son\w+='[^']*'/gi, "")
|
|
18
|
-
.replace(/href\s*=\s*"javascript:[^"]*"/gi, 'href="#"')
|
|
19
|
-
.replace(/href\s*=\s*'javascript:[^']*'/gi, "href='#'");
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
export function formatDate(iso: string): string {
|
|
23
|
-
try {
|
|
24
|
-
return new Intl.DateTimeFormat("en", {
|
|
25
|
-
month: "short",
|
|
26
|
-
day: "numeric",
|
|
27
|
-
hour: "numeric",
|
|
28
|
-
minute: "2-digit",
|
|
29
|
-
}).format(new Date(iso));
|
|
30
|
-
} catch {
|
|
31
|
-
return iso;
|
|
32
|
-
}
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
export function isFileAnswer(answer: string): boolean {
|
|
36
|
-
return answer?.includes("__isFile:true") || answer?.includes("map[__isFile");
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
export function formatAnswerText(value: string): string {
|
|
40
|
-
if (!value) return "";
|
|
41
|
-
|
|
42
|
-
const trimmed = value.trim();
|
|
43
|
-
if (trimmed.startsWith("[") && trimmed.endsWith("]")) {
|
|
44
|
-
const rawItems = trimmed.slice(1, -1).trim();
|
|
45
|
-
return rawItems.length > 0 ? rawItems.split(/\s+/).join(", ") : "";
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
return value;
|
|
49
|
-
}
|