@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,3 +1,12 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import type { SupportInitResponse } from "vicket";
|
|
3
|
+
|
|
4
|
+
const { data: initData, error: fetchError } = await useFetch<SupportInitResponse>('/api/vicket/init');
|
|
5
|
+
</script>
|
|
6
|
+
|
|
1
7
|
<template>
|
|
2
|
-
<
|
|
8
|
+
<VicketSupportContent
|
|
9
|
+
:init-data="initData?.data"
|
|
10
|
+
:init-error="fetchError?.message || ''"
|
|
11
|
+
/>
|
|
3
12
|
</template>
|
|
@@ -1,3 +1,300 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import type { TicketThread } from "vicket";
|
|
3
|
+
import {
|
|
4
|
+
cn,
|
|
5
|
+
stripHtml,
|
|
6
|
+
sanitizeHtml,
|
|
7
|
+
formatDate,
|
|
8
|
+
isFileAnswer,
|
|
9
|
+
formatAnswerText,
|
|
10
|
+
AUTHOR_LABELS,
|
|
11
|
+
} from "vicket";
|
|
12
|
+
|
|
13
|
+
/* ── Route & token ─────────────────────────────── */
|
|
14
|
+
const route = useRoute();
|
|
15
|
+
const token = computed(() => String(route.query.token || ""));
|
|
16
|
+
|
|
17
|
+
/* ── SSR data fetching ─────────────────────────── */
|
|
18
|
+
const { data: threadRaw, error: fetchError, refresh } = await useFetch<{
|
|
19
|
+
success?: boolean;
|
|
20
|
+
error?: string;
|
|
21
|
+
error_code?: string;
|
|
22
|
+
data?: TicketThread;
|
|
23
|
+
}>('/api/vicket/ticket', {
|
|
24
|
+
query: { token: token.value },
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
const thread = computed<TicketThread | null>(() => {
|
|
28
|
+
const raw = threadRaw.value;
|
|
29
|
+
if (!raw?.success || !raw?.data) return null;
|
|
30
|
+
return raw.data;
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
const error = ref(
|
|
34
|
+
fetchError.value
|
|
35
|
+
? "Failed to load ticket."
|
|
36
|
+
: threadRaw.value && !threadRaw.value.success
|
|
37
|
+
? (threadRaw.value.error_code === "ticket-link-expired"
|
|
38
|
+
? "This link has expired. A new secure link has been sent to your email."
|
|
39
|
+
: threadRaw.value.error || "Failed to load ticket.")
|
|
40
|
+
: "",
|
|
41
|
+
);
|
|
42
|
+
|
|
43
|
+
/* ── Computed ───────────────────────────────────── */
|
|
44
|
+
const firstReporterMessage = computed(() => {
|
|
45
|
+
if (!thread.value?.messages || thread.value.messages.length === 0) return null;
|
|
46
|
+
const sorted = [...thread.value.messages].sort(
|
|
47
|
+
(a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime(),
|
|
48
|
+
);
|
|
49
|
+
return sorted[0].author_type === "reporter" ? sorted[0] : null;
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
const sortedMessages = computed(() => {
|
|
53
|
+
if (!thread.value?.messages) return [];
|
|
54
|
+
return [...thread.value.messages]
|
|
55
|
+
.sort((a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime())
|
|
56
|
+
.filter((m) => !firstReporterMessage.value || m.id !== firstReporterMessage.value.id);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
const summaryAnswers = computed(() => {
|
|
60
|
+
if (!thread.value?.answers) return [];
|
|
61
|
+
return thread.value.answers.filter((answer) => {
|
|
62
|
+
if (answer.attachments && answer.attachments.length > 0) return true;
|
|
63
|
+
if (answer.answer && answer.answer.trim().length > 0) return true;
|
|
64
|
+
return false;
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
const avatarColors = (authorType: string) =>
|
|
69
|
+
authorType === "reporter"
|
|
70
|
+
? "bg-blue-600/15 text-blue-600"
|
|
71
|
+
: authorType === "user"
|
|
72
|
+
? "bg-emerald-500/15 text-emerald-500"
|
|
73
|
+
: "bg-slate-400/20 text-slate-400";
|
|
74
|
+
|
|
75
|
+
/* ── Reply callback ────────────────────────────── */
|
|
76
|
+
const onReplied = async () => {
|
|
77
|
+
await refresh();
|
|
78
|
+
};
|
|
79
|
+
</script>
|
|
80
|
+
|
|
1
81
|
<template>
|
|
2
|
-
<
|
|
82
|
+
<div class="min-h-screen bg-slate-50 font-[system-ui,-apple-system,sans-serif] antialiased">
|
|
83
|
+
<div class="mx-auto w-full max-w-3xl px-4 py-8">
|
|
84
|
+
<!-- Back link -->
|
|
85
|
+
<div class="mb-6">
|
|
86
|
+
<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">
|
|
87
|
+
<!-- IconArrowLeft -->
|
|
88
|
+
<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>
|
|
89
|
+
Back to support
|
|
90
|
+
</a>
|
|
91
|
+
</div>
|
|
92
|
+
|
|
93
|
+
<!-- Error alert -->
|
|
94
|
+
<div v-if="error" class="mb-4">
|
|
95
|
+
<div
|
|
96
|
+
class="flex items-start gap-3 rounded-xl border border-red-200 bg-red-50 p-4 text-sm text-red-900"
|
|
97
|
+
role="alert"
|
|
98
|
+
>
|
|
99
|
+
<span class="mt-0.5 shrink-0">
|
|
100
|
+
<!-- IconAlert -->
|
|
101
|
+
<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>
|
|
102
|
+
</span>
|
|
103
|
+
<span class="flex-1">{{ error }}</span>
|
|
104
|
+
<button
|
|
105
|
+
type="button"
|
|
106
|
+
class="shrink-0 cursor-pointer border-none bg-transparent p-0 opacity-50 transition-opacity hover:opacity-100"
|
|
107
|
+
aria-label="Dismiss"
|
|
108
|
+
@click="error = ''"
|
|
109
|
+
>
|
|
110
|
+
<!-- IconX -->
|
|
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="M18 6 6 18" /><path d="m6 6 12 12" /></svg>
|
|
112
|
+
</button>
|
|
113
|
+
</div>
|
|
114
|
+
</div>
|
|
115
|
+
|
|
116
|
+
<!-- Thread content -->
|
|
117
|
+
<div v-if="thread" class="space-y-6">
|
|
118
|
+
<!-- Header -->
|
|
119
|
+
<div>
|
|
120
|
+
<div class="flex flex-wrap items-start justify-between gap-3">
|
|
121
|
+
<h1 class="m-0 text-xl font-semibold tracking-tight text-slate-900 sm:text-2xl">{{ thread.title }}</h1>
|
|
122
|
+
<span
|
|
123
|
+
v-if="thread.id"
|
|
124
|
+
class="inline-flex items-center rounded-full border border-slate-200 bg-slate-50 px-2.5 py-0.5 text-xs font-semibold font-mono text-slate-500"
|
|
125
|
+
>
|
|
126
|
+
#{{ thread.id.slice(0, 8) }}
|
|
127
|
+
</span>
|
|
128
|
+
</div>
|
|
129
|
+
<div
|
|
130
|
+
v-if="(thread.status?.label && thread.status.label.toLowerCase() !== 'open') || (thread.priority?.label && thread.priority.label.toLowerCase() !== 'low')"
|
|
131
|
+
class="mt-2.5 flex flex-wrap items-center gap-2"
|
|
132
|
+
>
|
|
133
|
+
<span
|
|
134
|
+
v-if="thread.status?.label && thread.status.label.toLowerCase() !== 'open'"
|
|
135
|
+
class="inline-flex items-center rounded-full border border-blue-600/20 bg-blue-50 px-2.5 py-0.5 text-xs font-semibold text-blue-600"
|
|
136
|
+
>
|
|
137
|
+
{{ thread.status.label }}
|
|
138
|
+
</span>
|
|
139
|
+
<span
|
|
140
|
+
v-if="thread.priority?.label && thread.priority.label.toLowerCase() !== 'low'"
|
|
141
|
+
class="inline-flex items-center rounded-full border border-amber-200 bg-amber-50 px-2.5 py-0.5 text-xs font-semibold text-amber-700"
|
|
142
|
+
>
|
|
143
|
+
{{ thread.priority.label }}
|
|
144
|
+
</span>
|
|
145
|
+
</div>
|
|
146
|
+
</div>
|
|
147
|
+
|
|
148
|
+
<!-- Summary (ticket form answers) -->
|
|
149
|
+
<div v-if="summaryAnswers.length > 0" class="rounded-2xl border border-slate-200 bg-white shadow-sm">
|
|
150
|
+
<div class="flex items-center gap-2 border-b border-slate-200 px-5 py-3.5">
|
|
151
|
+
<!-- IconFile -->
|
|
152
|
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" class="text-slate-500"><path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z" /><path d="M14 2v4a2 2 0 0 0 2 2h4" /></svg>
|
|
153
|
+
<h2 class="m-0 text-sm font-semibold text-slate-900">Summary</h2>
|
|
154
|
+
</div>
|
|
155
|
+
<div class="space-y-3 p-5">
|
|
156
|
+
<div v-for="answer in summaryAnswers" :key="answer.id" class="rounded-xl bg-slate-50/70 p-4">
|
|
157
|
+
<p class="mb-2.5 text-xs font-medium text-slate-500">{{ answer.question_label || "Question" }}</p>
|
|
158
|
+
<!-- File attachments -->
|
|
159
|
+
<div v-if="answer.attachments && answer.attachments.length > 0" class="flex flex-wrap gap-1.5">
|
|
160
|
+
<a
|
|
161
|
+
v-for="att in answer.attachments"
|
|
162
|
+
:key="att.id"
|
|
163
|
+
:href="att.url"
|
|
164
|
+
target="_blank"
|
|
165
|
+
rel="noopener noreferrer"
|
|
166
|
+
class="inline-flex items-center gap-1.5 rounded-full border border-slate-200 bg-white px-3 py-1.5 text-xs text-slate-500 no-underline transition-colors hover:border-slate-300 hover:text-slate-900"
|
|
167
|
+
>
|
|
168
|
+
<!-- IconPaperclip -->
|
|
169
|
+
<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>
|
|
170
|
+
{{ att.original_filename }}
|
|
171
|
+
</a>
|
|
172
|
+
</div>
|
|
173
|
+
<!-- File answer placeholder -->
|
|
174
|
+
<p v-else-if="isFileAnswer(answer.answer)" class="text-sm italic text-slate-500">File uploaded</p>
|
|
175
|
+
<!-- Text answer -->
|
|
176
|
+
<p v-else class="whitespace-pre-wrap break-words rounded-md border border-slate-200/60 bg-white px-3 py-2 text-sm leading-relaxed text-slate-900">
|
|
177
|
+
{{ formatAnswerText(answer.answer) || "-" }}
|
|
178
|
+
</p>
|
|
179
|
+
</div>
|
|
180
|
+
</div>
|
|
181
|
+
</div>
|
|
182
|
+
|
|
183
|
+
<!-- Description (first reporter message) -->
|
|
184
|
+
<div v-if="firstReporterMessage" class="rounded-2xl border border-slate-200 bg-white shadow-sm">
|
|
185
|
+
<div class="flex items-center gap-2 border-b border-slate-200 px-5 py-3.5">
|
|
186
|
+
<!-- IconFile -->
|
|
187
|
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" class="text-slate-500"><path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z" /><path d="M14 2v4a2 2 0 0 0 2 2h4" /></svg>
|
|
188
|
+
<h2 class="m-0 text-sm font-semibold text-slate-900">Description</h2>
|
|
189
|
+
</div>
|
|
190
|
+
<div class="p-5">
|
|
191
|
+
<div
|
|
192
|
+
class="text-sm leading-relaxed text-slate-500 [&_a]:text-blue-600 [&_a]:underline [&_code]:font-mono [&_code]:text-[0.85em] [&_ol]:my-1 [&_ol]:pl-6 [&_p:last-child]:mb-0 [&_p]:mb-2 [&_pre]:overflow-x-auto [&_pre]:rounded-md [&_pre]:bg-slate-50 [&_pre]:p-3 [&_pre]:text-xs [&_ul]:my-1 [&_ul]:pl-6"
|
|
193
|
+
v-html="sanitizeHtml(firstReporterMessage.content)"
|
|
194
|
+
/>
|
|
195
|
+
<div
|
|
196
|
+
v-if="firstReporterMessage.attachments && firstReporterMessage.attachments.length > 0"
|
|
197
|
+
class="mt-3 flex flex-wrap gap-1.5"
|
|
198
|
+
>
|
|
199
|
+
<a
|
|
200
|
+
v-for="att in firstReporterMessage.attachments"
|
|
201
|
+
:key="att.id"
|
|
202
|
+
:href="att.url"
|
|
203
|
+
target="_blank"
|
|
204
|
+
rel="noopener noreferrer"
|
|
205
|
+
class="inline-flex items-center gap-1.5 rounded-full border border-slate-200 bg-slate-50 px-3 py-1.5 text-xs text-slate-500 no-underline transition-colors hover:border-slate-300 hover:text-slate-900"
|
|
206
|
+
>
|
|
207
|
+
<!-- IconPaperclip -->
|
|
208
|
+
<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>
|
|
209
|
+
{{ att.original_filename }}
|
|
210
|
+
</a>
|
|
211
|
+
</div>
|
|
212
|
+
</div>
|
|
213
|
+
</div>
|
|
214
|
+
|
|
215
|
+
<!-- Comments -->
|
|
216
|
+
<div class="rounded-2xl border border-slate-200 bg-white shadow-sm">
|
|
217
|
+
<div class="flex items-center gap-2 border-b border-slate-200 px-5 py-3.5">
|
|
218
|
+
<!-- IconMessageCircle -->
|
|
219
|
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" class="text-slate-500"><path d="M7.9 20A9 9 0 1 0 4 16.1L2 22Z" /></svg>
|
|
220
|
+
<h2 class="m-0 text-sm font-semibold text-slate-900">Comments</h2>
|
|
221
|
+
</div>
|
|
222
|
+
|
|
223
|
+
<!-- Reply form (client interactive) -->
|
|
224
|
+
<VicketReplyForm :token="token" @replied="onReplied" />
|
|
225
|
+
|
|
226
|
+
<!-- Empty messages -->
|
|
227
|
+
<div v-if="sortedMessages.length === 0" class="px-5 py-12 text-center">
|
|
228
|
+
<div class="mx-auto mb-3 flex h-10 w-10 items-center justify-center rounded-full bg-slate-50 text-slate-500">
|
|
229
|
+
<!-- IconMessageCircle -->
|
|
230
|
+
<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>
|
|
231
|
+
</div>
|
|
232
|
+
<p class="m-0 text-sm text-slate-500">No messages yet. Be the first to reply!</p>
|
|
233
|
+
</div>
|
|
234
|
+
|
|
235
|
+
<!-- Message list -->
|
|
236
|
+
<div v-else class="divide-y divide-slate-200">
|
|
237
|
+
<div v-for="message in sortedMessages" :key="message.id" class="flex gap-3 px-5 py-3.5">
|
|
238
|
+
<!-- System message -->
|
|
239
|
+
<template v-if="message.author_type === 'system'">
|
|
240
|
+
<div
|
|
241
|
+
:class="cn('mt-0.5 flex h-7 w-7 shrink-0 items-center justify-center rounded-full text-[10px] font-bold leading-none', avatarColors('system'))"
|
|
242
|
+
aria-hidden="true"
|
|
243
|
+
>
|
|
244
|
+
S
|
|
245
|
+
</div>
|
|
246
|
+
<div class="min-w-0 flex-1">
|
|
247
|
+
<div class="flex flex-wrap items-baseline gap-2">
|
|
248
|
+
<span class="text-xs font-medium text-slate-500">System</span>
|
|
249
|
+
<span class="text-xs text-slate-500/50">{{ formatDate(message.created_at) }}</span>
|
|
250
|
+
</div>
|
|
251
|
+
<p class="mt-1 text-xs italic text-slate-500">{{ message.content.replace(/<[^>]*>/g, "") }}</p>
|
|
252
|
+
</div>
|
|
253
|
+
</template>
|
|
254
|
+
|
|
255
|
+
<!-- Reporter / Support message -->
|
|
256
|
+
<template v-else>
|
|
257
|
+
<div
|
|
258
|
+
:class="cn('mt-0.5 flex h-7 w-7 shrink-0 items-center justify-center rounded-full text-[10px] font-bold leading-none', avatarColors(message.author_type))"
|
|
259
|
+
aria-hidden="true"
|
|
260
|
+
>
|
|
261
|
+
{{ (AUTHOR_LABELS[message.author_type] || "?")[0] }}
|
|
262
|
+
</div>
|
|
263
|
+
<div class="min-w-0 flex-1">
|
|
264
|
+
<div class="flex flex-wrap items-baseline gap-2">
|
|
265
|
+
<span class="text-sm font-medium text-slate-900">{{ AUTHOR_LABELS[message.author_type] || message.author_type }}</span>
|
|
266
|
+
<span class="text-xs text-slate-500/50">{{ formatDate(message.created_at) }}</span>
|
|
267
|
+
</div>
|
|
268
|
+
<div
|
|
269
|
+
:class="cn(
|
|
270
|
+
'mt-1.5 break-words rounded-xl px-4 py-3 text-sm leading-relaxed [&_a]:text-blue-600 [&_a]:underline [&_code]:font-mono [&_code]:text-[0.85em] [&_ol]:my-1 [&_ol]:pl-6 [&_p:last-child]:mb-0 [&_p]:mb-2 [&_pre]:overflow-x-auto [&_pre]:rounded-md [&_pre]:bg-slate-50 [&_pre]:p-3 [&_pre]:text-xs [&_ul]:my-1 [&_ul]:pl-6',
|
|
271
|
+
message.author_type === 'user' ? 'border border-blue-600/10 bg-blue-50 text-slate-800' : 'border border-slate-200 bg-slate-50 text-slate-800',
|
|
272
|
+
)"
|
|
273
|
+
v-html="sanitizeHtml(message.content)"
|
|
274
|
+
/>
|
|
275
|
+
<div
|
|
276
|
+
v-if="message.attachments && message.attachments.length > 0"
|
|
277
|
+
class="mt-2 flex flex-wrap gap-1.5"
|
|
278
|
+
>
|
|
279
|
+
<a
|
|
280
|
+
v-for="att in message.attachments"
|
|
281
|
+
:key="att.id"
|
|
282
|
+
class="inline-flex items-center gap-1.5 rounded-full border border-slate-200 bg-white px-3 py-1.5 text-xs text-slate-500 no-underline transition-all duration-150 hover:border-slate-300 hover:text-slate-900"
|
|
283
|
+
:href="att.url"
|
|
284
|
+
rel="noopener noreferrer"
|
|
285
|
+
target="_blank"
|
|
286
|
+
>
|
|
287
|
+
<!-- IconPaperclip -->
|
|
288
|
+
<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>
|
|
289
|
+
{{ att.original_filename }}
|
|
290
|
+
</a>
|
|
291
|
+
</div>
|
|
292
|
+
</div>
|
|
293
|
+
</template>
|
|
294
|
+
</div>
|
|
295
|
+
</div>
|
|
296
|
+
</div>
|
|
297
|
+
</div>
|
|
298
|
+
</div>
|
|
299
|
+
</div>
|
|
3
300
|
</template>
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { sendReply } from "vicket";
|
|
3
|
+
import { invalidateAll } from "$app/navigation";
|
|
4
|
+
|
|
5
|
+
let { token }: { token: string } = $props();
|
|
6
|
+
|
|
7
|
+
let content = $state("");
|
|
8
|
+
let files = $state<File[]>([]);
|
|
9
|
+
let isSending = $state(false);
|
|
10
|
+
let error = $state("");
|
|
11
|
+
let success = $state("");
|
|
12
|
+
|
|
13
|
+
function removeFile(index: number) {
|
|
14
|
+
files = files.filter((_, i) => i !== index);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function onFileChange(event: Event) {
|
|
18
|
+
const input = event.target as HTMLInputElement;
|
|
19
|
+
const newFiles = Array.from(input.files || []);
|
|
20
|
+
files = [...files, ...newFiles];
|
|
21
|
+
input.value = "";
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
async function onSubmitReply(event: SubmitEvent) {
|
|
25
|
+
event.preventDefault();
|
|
26
|
+
error = "";
|
|
27
|
+
success = "";
|
|
28
|
+
|
|
29
|
+
if (!content.trim() && files.length === 0) {
|
|
30
|
+
error = "Reply content is required.";
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
if (!token.trim()) {
|
|
35
|
+
error = "Missing ticket token.";
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
isSending = true;
|
|
40
|
+
try {
|
|
41
|
+
await sendReply(token, content.trim(), files);
|
|
42
|
+
content = "";
|
|
43
|
+
files = [];
|
|
44
|
+
success = "Reply sent.";
|
|
45
|
+
await invalidateAll();
|
|
46
|
+
} catch (replyError) {
|
|
47
|
+
error = replyError instanceof Error ? replyError.message : "Unexpected error.";
|
|
48
|
+
} finally {
|
|
49
|
+
isSending = false;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
</script>
|
|
53
|
+
|
|
54
|
+
<!-- Alerts -->
|
|
55
|
+
{#if error}
|
|
56
|
+
<div class="mb-4 flex items-start gap-3 rounded-xl border border-red-200 bg-red-50 p-4 text-sm text-red-900" role="alert">
|
|
57
|
+
<span class="mt-0.5 shrink-0">
|
|
58
|
+
<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>
|
|
59
|
+
</span>
|
|
60
|
+
<span class="flex-1">{error}</span>
|
|
61
|
+
<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">
|
|
62
|
+
<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>
|
|
63
|
+
</button>
|
|
64
|
+
</div>
|
|
65
|
+
{/if}
|
|
66
|
+
{#if success}
|
|
67
|
+
<div class="mb-4 flex items-start gap-3 rounded-xl border border-green-200 bg-green-50 p-4 text-sm text-green-900" role="alert">
|
|
68
|
+
<span class="mt-0.5 shrink-0">
|
|
69
|
+
<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="M20 6 9 17l-5-5" /></svg>
|
|
70
|
+
</span>
|
|
71
|
+
<span class="flex-1">{success}</span>
|
|
72
|
+
<button type="button" onclick={() => (success = "")} class="shrink-0 cursor-pointer border-none bg-transparent p-0 opacity-50 transition-opacity hover:opacity-100" aria-label="Dismiss">
|
|
73
|
+
<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>
|
|
74
|
+
</button>
|
|
75
|
+
</div>
|
|
76
|
+
{/if}
|
|
77
|
+
|
|
78
|
+
<!-- Compose area -->
|
|
79
|
+
<div class="border-b border-slate-100 px-5 py-4">
|
|
80
|
+
<form class="space-y-3" onsubmit={onSubmitReply}>
|
|
81
|
+
<textarea
|
|
82
|
+
class="min-h-[80px] w-full resize-y rounded-xl border border-slate-200 bg-slate-50 px-4 py-3 text-sm text-slate-900 transition-all duration-150 placeholder:text-slate-500/60 focus:border-blue-600 focus:outline-none focus:ring-3 focus:ring-blue-600/10"
|
|
83
|
+
bind:value={content}
|
|
84
|
+
placeholder="Write your reply..."
|
|
85
|
+
></textarea>
|
|
86
|
+
|
|
87
|
+
<div class="flex items-center justify-between gap-3">
|
|
88
|
+
<!-- File input -->
|
|
89
|
+
<div class="flex flex-wrap items-center gap-2">
|
|
90
|
+
<label class="inline-flex cursor-pointer items-center gap-1.5 rounded-lg border border-slate-200 bg-white px-3 py-1.5 text-xs font-medium text-slate-600 transition-colors hover:bg-slate-50">
|
|
91
|
+
<svg width="14" height="14" 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>
|
|
92
|
+
Browse files
|
|
93
|
+
<input type="file" multiple class="hidden" onchange={onFileChange} />
|
|
94
|
+
</label>
|
|
95
|
+
{#each files as file, i}
|
|
96
|
+
<span class="inline-flex items-center gap-1.5 rounded-full border border-slate-200 bg-slate-50 px-3 py-1.5 text-xs text-slate-500">
|
|
97
|
+
<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>
|
|
98
|
+
<span class="max-w-[120px] truncate">{file.name}</span>
|
|
99
|
+
<button
|
|
100
|
+
type="button"
|
|
101
|
+
onclick={() => removeFile(i)}
|
|
102
|
+
class="flex cursor-pointer items-center border-none bg-transparent p-0 text-slate-400 transition-colors hover:text-red-600"
|
|
103
|
+
aria-label="Remove {file.name}"
|
|
104
|
+
>
|
|
105
|
+
<svg width="14" height="14" 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>
|
|
106
|
+
</button>
|
|
107
|
+
</span>
|
|
108
|
+
{/each}
|
|
109
|
+
</div>
|
|
110
|
+
|
|
111
|
+
<!-- Send -->
|
|
112
|
+
<button
|
|
113
|
+
class="inline-flex shrink-0 items-center gap-2 rounded-lg border-none bg-blue-600 px-5 py-2 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 disabled:opacity-60 disabled:cursor-not-allowed"
|
|
114
|
+
disabled={isSending}
|
|
115
|
+
type="submit"
|
|
116
|
+
>
|
|
117
|
+
{#if isSending}
|
|
118
|
+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" class="animate-spin"><path d="M21 12a9 9 0 1 1-6.219-8.56" /></svg>
|
|
119
|
+
Sending...
|
|
120
|
+
{:else}
|
|
121
|
+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M14.536 21.686a.5.5 0 0 0 .937-.024l6.5-19a.496.496 0 0 0-.635-.635l-19 6.5a.5.5 0 0 0-.024.937l7.93 3.18a2 2 0 0 1 1.112 1.11z" /><path d="m21.854 2.147-10.94 10.939" /></svg>
|
|
122
|
+
Send
|
|
123
|
+
{/if}
|
|
124
|
+
</button>
|
|
125
|
+
</div>
|
|
126
|
+
</form>
|
|
127
|
+
</div>
|
package/templates-tailwind/sveltekit/src/lib/vicket/{SupportPage.svelte → SupportContent.svelte}
RENAMED
|
@@ -1,17 +1,17 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
|
-
import type
|
|
3
|
-
import { fetchInit, sanitizeHtml, stripHtml, cn } from "$lib/vicket";
|
|
2
|
+
import { cn, stripHtml, sanitizeHtml, type Template, type Article, type Faq } from "vicket";
|
|
4
3
|
import TicketDialog from "./TicketDialog.svelte";
|
|
5
4
|
|
|
5
|
+
let { initData = null, initError = '' } = $props();
|
|
6
|
+
|
|
6
7
|
/* ---------------------------------------------- */
|
|
7
8
|
/* State */
|
|
8
9
|
/* ---------------------------------------------- */
|
|
9
|
-
let templates = $state<Template[]>([]);
|
|
10
|
-
let articles = $state<Article[]>([]);
|
|
11
|
-
let faqs = $state<Faq[]>([]);
|
|
12
|
-
let websiteName = $state("Support");
|
|
13
|
-
let
|
|
14
|
-
let error = $state("");
|
|
10
|
+
let templates = $state<Template[]>(initData?.templates || []);
|
|
11
|
+
let articles = $state<Article[]>(initData?.articles || []);
|
|
12
|
+
let faqs = $state<Faq[]>(initData?.faqs || []);
|
|
13
|
+
let websiteName = $state(initData?.website?.name || "Support");
|
|
14
|
+
let error = $state(initError || "");
|
|
15
15
|
let searchQuery = $state("");
|
|
16
16
|
let dialogOpen = $state(false);
|
|
17
17
|
let selectedArticle = $state<Article | null>(null);
|
|
@@ -53,74 +53,12 @@
|
|
|
53
53
|
}
|
|
54
54
|
openFaqIds = next;
|
|
55
55
|
}
|
|
56
|
-
|
|
57
|
-
/* ---------------------------------------------- */
|
|
58
|
-
/* Load data on mount */
|
|
59
|
-
/* ---------------------------------------------- */
|
|
60
|
-
$effect(() => {
|
|
61
|
-
let isMounted = true;
|
|
62
|
-
const load = async () => {
|
|
63
|
-
isLoading = true;
|
|
64
|
-
error = "";
|
|
65
|
-
try {
|
|
66
|
-
const data = await fetchInit();
|
|
67
|
-
if (!isMounted) return;
|
|
68
|
-
templates = data.templates || [];
|
|
69
|
-
articles = data.articles || [];
|
|
70
|
-
faqs = data.faqs || [];
|
|
71
|
-
websiteName = data.website?.name || "Support";
|
|
72
|
-
} catch (loadError) {
|
|
73
|
-
if (!isMounted) return;
|
|
74
|
-
error = loadError instanceof Error ? loadError.message : "Unexpected error.";
|
|
75
|
-
} finally {
|
|
76
|
-
if (isMounted) isLoading = false;
|
|
77
|
-
}
|
|
78
|
-
};
|
|
79
|
-
load();
|
|
80
|
-
return () => { isMounted = false; };
|
|
81
|
-
});
|
|
82
56
|
</script>
|
|
83
57
|
|
|
84
|
-
<!-- --------------------------------------- -->
|
|
85
|
-
<!-- Loading skeleton -->
|
|
86
|
-
<!-- --------------------------------------- -->
|
|
87
|
-
{#if isLoading}
|
|
88
|
-
<div class="min-h-screen bg-slate-50 font-[system-ui,-apple-system,sans-serif] antialiased">
|
|
89
|
-
<div class="mx-auto max-w-5xl px-6 py-16">
|
|
90
|
-
<!-- Header skeleton -->
|
|
91
|
-
<div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
|
92
|
-
<div>
|
|
93
|
-
<div class="h-8 w-40 animate-pulse rounded-lg bg-slate-200" aria-hidden="true"></div>
|
|
94
|
-
<div class="mt-2 h-5 w-64 animate-pulse rounded-lg bg-slate-200" aria-hidden="true"></div>
|
|
95
|
-
</div>
|
|
96
|
-
<div class="h-12 w-44 animate-pulse rounded-full bg-slate-200" aria-hidden="true"></div>
|
|
97
|
-
</div>
|
|
98
|
-
<!-- Search skeleton -->
|
|
99
|
-
<div class="mt-10">
|
|
100
|
-
<div class="h-12 w-full animate-pulse rounded-xl bg-slate-200" aria-hidden="true"></div>
|
|
101
|
-
</div>
|
|
102
|
-
<!-- Content skeleton -->
|
|
103
|
-
<div class="mt-10 grid gap-10 md:grid-cols-2">
|
|
104
|
-
<div class="space-y-3">
|
|
105
|
-
<div class="h-5 w-36 animate-pulse rounded-lg bg-slate-200" aria-hidden="true"></div>
|
|
106
|
-
<div class="h-16 w-full animate-pulse rounded-xl bg-slate-200" aria-hidden="true"></div>
|
|
107
|
-
<div class="h-16 w-full animate-pulse rounded-xl bg-slate-200" aria-hidden="true"></div>
|
|
108
|
-
<div class="h-16 w-full animate-pulse rounded-xl bg-slate-200" aria-hidden="true"></div>
|
|
109
|
-
</div>
|
|
110
|
-
<div class="space-y-3">
|
|
111
|
-
<div class="h-5 w-52 animate-pulse rounded-lg bg-slate-200" aria-hidden="true"></div>
|
|
112
|
-
<div class="h-14 w-full animate-pulse rounded-xl bg-slate-200" aria-hidden="true"></div>
|
|
113
|
-
<div class="h-14 w-full animate-pulse rounded-xl bg-slate-200" aria-hidden="true"></div>
|
|
114
|
-
<div class="h-14 w-full animate-pulse rounded-xl bg-slate-200" aria-hidden="true"></div>
|
|
115
|
-
</div>
|
|
116
|
-
</div>
|
|
117
|
-
</div>
|
|
118
|
-
</div>
|
|
119
|
-
|
|
120
58
|
<!-- --------------------------------------- -->
|
|
121
59
|
<!-- Article viewer -->
|
|
122
60
|
<!-- --------------------------------------- -->
|
|
123
|
-
{
|
|
61
|
+
{#if selectedArticle}
|
|
124
62
|
<div class="min-h-screen bg-slate-50 font-[system-ui,-apple-system,sans-serif] antialiased">
|
|
125
63
|
<div class="mx-auto max-w-5xl px-6 py-16">
|
|
126
64
|
<!-- Hero stays visible -->
|