@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.
Files changed (71) hide show
  1. package/bin/create-vicket-support.js +429 -389
  2. package/package.json +1 -1
  3. package/templates/next/src/app/api/vicket/[...path]/route.ts +2 -55
  4. package/templates/next/src/app/components/vicket/ReplyForm.tsx +154 -0
  5. package/templates/next/src/app/components/vicket/SupportContent.tsx +298 -0
  6. package/templates/next/src/app/components/vicket/TicketDialog.tsx +3 -3
  7. package/templates/next/src/app/support/page.tsx +27 -353
  8. package/templates/next/src/app/ticket/page.tsx +110 -325
  9. package/templates/next/src/app/vicket.css +1325 -1325
  10. package/templates/nuxt/app/assets/css/vicket.css +1325 -1325
  11. package/templates/nuxt/app/components/VicketReplyForm.vue +154 -0
  12. package/templates/nuxt/app/components/VicketSupportContent.vue +255 -0
  13. package/templates/nuxt/app/components/VicketTicketDialog.vue +2 -2
  14. package/templates/nuxt/app/pages/support.vue +7 -293
  15. package/templates/nuxt/app/pages/ticket.vue +36 -178
  16. package/templates/nuxt/server/api/vicket/[...path].ts +2 -85
  17. package/templates/sveltekit/src/lib/vicket/ReplyForm.svelte +134 -0
  18. package/templates/sveltekit/src/lib/vicket/SupportContent.svelte +263 -0
  19. package/templates/sveltekit/src/lib/vicket/TicketDialog.svelte +457 -459
  20. package/templates/sveltekit/src/lib/vicket.css +1325 -1325
  21. package/templates/sveltekit/src/routes/api/vicket/[...path]/+server.ts +2 -76
  22. package/templates/sveltekit/src/routes/support/+page.server.ts +13 -0
  23. package/templates/sveltekit/src/routes/support/+page.svelte +3 -312
  24. package/templates/sveltekit/src/routes/ticket/+page.server.ts +19 -0
  25. package/templates/sveltekit/src/routes/ticket/+page.svelte +13 -188
  26. package/templates-tailwind/next/src/app/api/vicket/[...path]/route.ts +6 -0
  27. package/templates-tailwind/next/src/app/support/page.tsx +33 -3
  28. package/templates-tailwind/next/src/app/ticket/page.tsx +249 -6
  29. package/templates-tailwind/next/src/components/vicket/reply-form.tsx +113 -0
  30. package/templates-tailwind/next/src/components/vicket/support-content.tsx +265 -0
  31. package/templates-tailwind/next/src/components/vicket/ticket-dialog.tsx +2 -2
  32. package/templates-tailwind/nuxt/app/components/VicketReplyForm.vue +169 -0
  33. package/templates-tailwind/nuxt/app/components/{VicketSupportPage.vue → VicketSupportContent.vue} +275 -317
  34. package/templates-tailwind/nuxt/app/components/VicketTicketDialog.vue +3 -0
  35. package/templates-tailwind/nuxt/app/pages/support.vue +10 -1
  36. package/templates-tailwind/nuxt/app/pages/ticket.vue +298 -1
  37. package/templates-tailwind/nuxt/server/api/vicket/[...path].ts +2 -0
  38. package/templates-tailwind/sveltekit/src/lib/vicket/ReplyForm.svelte +127 -0
  39. package/templates-tailwind/sveltekit/src/lib/vicket/{SupportPage.svelte → SupportContent.svelte} +9 -71
  40. package/templates-tailwind/sveltekit/src/lib/vicket/TicketDialog.svelte +405 -406
  41. package/templates-tailwind/sveltekit/src/routes/api/vicket/[...path]/+server.ts +3 -0
  42. package/templates-tailwind/sveltekit/src/routes/support/+page.server.ts +13 -0
  43. package/templates-tailwind/sveltekit/src/routes/support/+page.svelte +4 -2
  44. package/templates-tailwind/sveltekit/src/routes/ticket/+page.server.ts +19 -0
  45. package/templates-tailwind/sveltekit/src/routes/ticket/+page.svelte +292 -2
  46. package/templates/next/src/app/utils/vicket/api.ts +0 -149
  47. package/templates/next/src/app/utils/vicket/types.ts +0 -85
  48. package/templates/next/src/app/utils/vicket/utils.ts +0 -49
  49. package/templates/nuxt/app/composables/useVicket.ts +0 -274
  50. package/templates/sveltekit/src/lib/vicket/api.ts +0 -162
  51. package/templates/sveltekit/src/lib/vicket/types.ts +0 -87
  52. package/templates/sveltekit/src/lib/vicket/utils.ts +0 -55
  53. package/templates-tailwind/next/src/app/api/vicket/init/route.ts +0 -24
  54. package/templates-tailwind/next/src/app/api/vicket/messages/route.ts +0 -36
  55. package/templates-tailwind/next/src/app/api/vicket/thread/route.ts +0 -27
  56. package/templates-tailwind/next/src/app/api/vicket/tickets/route.ts +0 -37
  57. package/templates-tailwind/next/src/components/vicket/support-page.tsx +0 -359
  58. package/templates-tailwind/next/src/components/vicket/ticket-page.tsx +0 -425
  59. package/templates-tailwind/next/src/lib/vicket.ts +0 -257
  60. package/templates-tailwind/nuxt/app/components/VicketTicketPage.vue +0 -449
  61. package/templates-tailwind/nuxt/app/composables/use-vicket.ts +0 -249
  62. package/templates-tailwind/nuxt/server/api/vicket/init.get.ts +0 -22
  63. package/templates-tailwind/nuxt/server/api/vicket/messages.post.ts +0 -56
  64. package/templates-tailwind/nuxt/server/api/vicket/thread.get.ts +0 -26
  65. package/templates-tailwind/nuxt/server/api/vicket/tickets.post.ts +0 -53
  66. package/templates-tailwind/sveltekit/src/lib/vicket/TicketPage.svelte +0 -465
  67. package/templates-tailwind/sveltekit/src/lib/vicket/index.ts +0 -257
  68. package/templates-tailwind/sveltekit/src/routes/api/vicket/init/+server.ts +0 -22
  69. package/templates-tailwind/sveltekit/src/routes/api/vicket/messages/+server.ts +0 -40
  70. package/templates-tailwind/sveltekit/src/routes/api/vicket/thread/+server.ts +0 -25
  71. package/templates-tailwind/sveltekit/src/routes/api/vicket/tickets/+server.ts +0 -37
@@ -1,4 +1,7 @@
1
1
  <script setup lang="ts">
2
+ import type { Template, FormValues } from "vicket";
3
+ import { cn, initialFormValues, createTicket } from "vicket";
4
+
2
5
  /* ── Props / Emits ──────────────────────────────── */
3
6
  const props = defineProps<{
4
7
  open: boolean;
@@ -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
- <VicketSupportPage />
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
- <VicketTicketPage />
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,2 @@
1
+ import { createVicketProxy } from "vicket/nuxt";
2
+ export default createVicketProxy();
@@ -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>
@@ -1,17 +1,17 @@
1
1
  <script lang="ts">
2
- import type { Template, Article, Faq } from "$lib/vicket";
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 isLoading = $state(true);
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
- {:else if selectedArticle}
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 -->