@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,406 +1,405 @@
1
- <script lang="ts">
2
- import type { Template, FormValues } from "$lib/vicket";
3
- import { createTicket, initialFormValues, cn } from "$lib/vicket";
4
-
5
- let {
6
- open,
7
- onclose,
8
- templates,
9
- }: {
10
- open: boolean;
11
- onclose: () => void;
12
- templates: Template[];
13
- } = $props();
14
-
15
- /* -- Internal state -- */
16
- let step = $state<"identify" | "details" | "success">("identify");
17
- let selectedTemplateId = $state("");
18
- let form = $state<FormValues>({ ...initialFormValues });
19
- let isSubmitting = $state(false);
20
- let dialogError = $state("");
21
- let emailLimitReached = $state(false);
22
-
23
- /* -- Derived -- */
24
- let selectedTemplate = $derived(templates.find((t) => t.id === selectedTemplateId) || null);
25
- let orderedQuestions = $derived(
26
- [...(selectedTemplate?.questions || [])].sort((a, b) => a.order - b.order),
27
- );
28
- let canContinue = $derived(form.email.trim().length > 0);
29
-
30
- /* -- Sync selectedTemplateId when templates changes -- */
31
- $effect(() => {
32
- if (templates.length > 0 && !selectedTemplateId) {
33
- selectedTemplateId = templates[0].id;
34
- }
35
- });
36
-
37
- /* -- Reset when dialog opens -- */
38
- $effect(() => {
39
- if (open) {
40
- step = "identify";
41
- form = { ...initialFormValues };
42
- dialogError = "";
43
- emailLimitReached = false;
44
- selectedTemplateId = templates.length > 0 ? templates[0].id : "";
45
- }
46
- });
47
-
48
- /* -- Functions -- */
49
- function resetAndClose() {
50
- step = "identify";
51
- form = { ...initialFormValues };
52
- dialogError = "";
53
- selectedTemplateId = templates.length > 0 ? templates[0].id : "";
54
- onclose();
55
- }
56
-
57
- function updateAnswer(questionId: string, value: unknown) {
58
- form = { ...form, answers: { ...form.answers, [questionId]: value } };
59
- }
60
-
61
- function toggleCheckboxValue(questionId: string, value: string, checked: boolean) {
62
- const current = Array.isArray(form.answers[questionId])
63
- ? (form.answers[questionId] as string[])
64
- : [];
65
- const next = checked
66
- ? [...new Set([...current, value])]
67
- : current.filter((item) => item !== value);
68
- updateAnswer(questionId, next);
69
- }
70
-
71
- function validateRequired(): string {
72
- if (!selectedTemplate) return "Please select a template.";
73
- if (!form.email.trim()) return "Email is required.";
74
- if (!form.title.trim()) return "Subject is required.";
75
-
76
- for (const question of orderedQuestions) {
77
- if (!question.required) continue;
78
- const value = form.answers[question.id];
79
-
80
- if (question.type === "CHECKBOX") {
81
- if (!Array.isArray(value) || value.length === 0)
82
- return `"${question.label}" is required.`;
83
- continue;
84
- }
85
- if (question.type === "FILE") {
86
- if (!(value instanceof File)) return `"${question.label}" is required.`;
87
- continue;
88
- }
89
- if (value === null || value === undefined || String(value).trim() === "") {
90
- return `"${question.label}" is required.`;
91
- }
92
- }
93
- return "";
94
- }
95
-
96
- async function handleSubmit(event: SubmitEvent) {
97
- event.preventDefault();
98
- dialogError = "";
99
-
100
- const validationError = validateRequired();
101
- if (validationError) {
102
- dialogError = validationError;
103
- return;
104
- }
105
- if (!selectedTemplate) {
106
- dialogError = "Template is required.";
107
- return;
108
- }
109
-
110
- isSubmitting = true;
111
- try {
112
- const fileQuestionIds = orderedQuestions
113
- .filter((q) => q.type === "FILE" && form.answers[q.id] instanceof File)
114
- .map((q) => q.id);
115
-
116
- const result = await createTicket({
117
- email: form.email.trim(),
118
- title: form.title.trim(),
119
- templateId: selectedTemplate.id,
120
- answers: { ...form.answers },
121
- hasFiles: fileQuestionIds.length > 0,
122
- fileQuestionIds,
123
- });
124
-
125
- emailLimitReached = result.emailLimitReached ?? false;
126
- step = "success";
127
- } catch (submitError) {
128
- dialogError = submitError instanceof Error ? submitError.message : "Unexpected error.";
129
- } finally {
130
- isSubmitting = false;
131
- }
132
- }
133
- </script>
134
-
135
- {#if open}
136
- <!-- svelte-ignore a11y_click_events_have_key_events -->
137
- <!-- svelte-ignore a11y_no_static_element_interactions -->
138
- <div class="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm" onclick={resetAndClose}>
139
- <!-- svelte-ignore a11y_click_events_have_key_events -->
140
- <!-- svelte-ignore a11y_no_static_element_interactions -->
141
- <div class="relative mx-4 w-full max-w-lg overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-xl" onclick={(e) => e.stopPropagation()}>
142
- <!-- Close button -->
143
- <button
144
- type="button"
145
- onclick={resetAndClose}
146
- class="absolute right-4 top-4 z-10 flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg border-none bg-transparent text-slate-500 transition-colors hover:bg-slate-50 hover:text-slate-900"
147
- aria-label="Close"
148
- >
149
- <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M18 6 6 18" /><path d="m6 6 12 12" /></svg>
150
- </button>
151
-
152
- {#if step === "success"}
153
- <div class="px-6 py-12 text-center">
154
- <div class="mx-auto mb-5 flex h-14 w-14 items-center justify-center rounded-full bg-green-50 text-green-600">
155
- <svg width="28" height="28" 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>
156
- </div>
157
- <h2 class="m-0 text-xl font-bold text-slate-900">Ticket submitted!</h2>
158
- {#if emailLimitReached}
159
- <p class="mt-2 text-sm text-slate-500">
160
- Your ticket was created, but the daily email limit for this service has been reached. No confirmation email was sent. Please consider using a self-hosted email delivery setup for unlimited emails.
161
- </p>
162
- {:else}
163
- <p class="mt-2 text-sm text-slate-500">
164
- Check your email for a secure link to follow your ticket.
165
- </p>
166
- {/if}
167
- <button
168
- type="button"
169
- class="mt-6 inline-flex items-center gap-2 !rounded-full border border-slate-200 bg-white !px-7 !py-3 text-sm font-semibold text-slate-900 cursor-pointer transition-all hover:border-slate-300 hover:bg-slate-50"
170
- onclick={resetAndClose}
171
- >
172
- Close
173
- </button>
174
- </div>
175
- {:else if step === "identify"}
176
- <div class="px-6 py-6">
177
- <h2 class="m-0 text-lg font-bold text-slate-900">Submit a request</h2>
178
- <p class="mt-1 text-sm text-slate-500">We'll get back to you as soon as possible.</p>
179
- <div class="mt-5 space-y-5">
180
- {#if dialogError}
181
- <div class={cn("flex items-start gap-3 rounded-xl border p-4 text-sm", "border-red-200 bg-red-50 text-red-900")} role="alert">
182
- <span class="mt-0.5 shrink-0">
183
- <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>
184
- </span>
185
- <span class="flex-1">{dialogError}</span>
186
- <button type="button" onclick={() => (dialogError = "")} class="shrink-0 cursor-pointer border-none bg-transparent p-0 opacity-50 transition-opacity hover:opacity-100" aria-label="Dismiss">
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"><path d="M18 6 6 18" /><path d="m6 6 12 12" /></svg>
188
- </button>
189
- </div>
190
- {/if}
191
-
192
- <!-- Email -->
193
- <div class="space-y-1.5">
194
- <label class="text-sm font-semibold text-slate-900" for="vk-dialog-email">Email<span class="text-red-600"> *</span></label>
195
- <div class="relative">
196
- <span class="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-slate-500">
197
- <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><rect width="20" height="16" x="2" y="4" rx="2" /><path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7" /></svg>
198
- </span>
199
- <input
200
- id="vk-dialog-email"
201
- class="w-full rounded-lg border border-slate-200 bg-slate-50 py-2.5 pl-10 pr-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"
202
- type="email"
203
- placeholder="you@example.com"
204
- bind:value={form.email}
205
- required
206
- />
207
- </div>
208
- <p class="text-xs text-slate-500">We'll contact you at this address.</p>
209
- </div>
210
-
211
- <!-- Template selector -->
212
- {#if templates.length > 1}
213
- <div class="space-y-2">
214
- <p class="m-0 text-sm font-semibold text-slate-900">What can we help you with?</p>
215
- <div class="grid gap-2">
216
- {#each templates as template (template.id)}
217
- <button
218
- type="button"
219
- class={cn(
220
- "flex w-full cursor-pointer items-start gap-3 rounded-xl border border-slate-200 bg-slate-50 p-3.5 text-left transition-all duration-150 hover:border-slate-300",
221
- selectedTemplateId === template.id && "!border-blue-600 !bg-blue-50 ring-3 ring-blue-600/12",
222
- )}
223
- onclick={() => (selectedTemplateId = template.id)}
224
- >
225
- <span class={cn(
226
- "mt-0.5 flex h-4 w-4 shrink-0 items-center justify-center rounded-full border-2 transition-colors",
227
- selectedTemplateId === template.id
228
- ? "border-blue-600 bg-blue-600"
229
- : "border-slate-300 bg-transparent",
230
- )}>
231
- {#if selectedTemplateId === template.id}
232
- <span class="block h-1.5 w-1.5 rounded-full bg-white"></span>
233
- {/if}
234
- </span>
235
- <div>
236
- <span class="block text-sm font-semibold text-slate-900">{template.name}</span>
237
- {#if template.description}
238
- <span class="mt-0.5 block text-xs text-slate-500">{template.description}</span>
239
- {/if}
240
- </div>
241
- </button>
242
- {/each}
243
- </div>
244
- </div>
245
- {/if}
246
-
247
- <!-- Continue -->
248
- <button
249
- type="button"
250
- class="inline-flex w-full items-center justify-center gap-2 rounded-lg border-none bg-blue-600 px-5 py-2.5 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"
251
- disabled={!canContinue}
252
- onclick={() => { dialogError = ""; step = "details"; }}
253
- >
254
- Continue
255
- </button>
256
- </div>
257
- </div>
258
- {:else if step === "details"}
259
- <div class="px-6 py-6">
260
- <button
261
- type="button"
262
- class="-ml-2 -mt-1 mb-3 inline-flex cursor-pointer items-center gap-1 rounded-lg border-none bg-transparent px-2 py-1 text-sm font-medium text-slate-500 transition-colors hover:bg-slate-50 hover:text-slate-900"
263
- onclick={() => { dialogError = ""; step = "identify"; }}
264
- >
265
- <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="m12 19-7-7 7-7" /><path d="M19 12H5" /></svg>
266
- Back
267
- </button>
268
-
269
- <h2 class="m-0 text-lg font-bold text-slate-900">
270
- {selectedTemplate?.name || "Ticket details"}
271
- </h2>
272
-
273
- <form class="mt-5 space-y-4" onsubmit={handleSubmit}>
274
- {#if dialogError}
275
- <div class={cn("flex items-start gap-3 rounded-xl border p-4 text-sm", "border-red-200 bg-red-50 text-red-900")} role="alert">
276
- <span class="mt-0.5 shrink-0">
277
- <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>
278
- </span>
279
- <span class="flex-1">{dialogError}</span>
280
- <button type="button" onclick={() => (dialogError = "")} class="shrink-0 cursor-pointer border-none bg-transparent p-0 opacity-50 transition-opacity hover:opacity-100" aria-label="Dismiss">
281
- <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>
282
- </button>
283
- </div>
284
- {/if}
285
-
286
- <!-- Subject -->
287
- <div class="space-y-1.5">
288
- <label class="text-sm font-semibold text-slate-900" for="vk-dialog-subject">Subject<span class="text-red-600"> *</span></label>
289
- <input
290
- id="vk-dialog-subject"
291
- class="w-full rounded-lg border border-slate-200 bg-slate-50 px-3 py-2.5 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"
292
- type="text"
293
- placeholder="Brief description of your issue"
294
- bind:value={form.title}
295
- required
296
- />
297
- </div>
298
-
299
- <!-- Dynamic questions -->
300
- {#each orderedQuestions as question (question.id)}
301
- <div class="space-y-1.5">
302
- <label class="text-sm font-semibold text-slate-900">
303
- {question.label}{#if question.required}<span class="text-red-600"> *</span>{/if}
304
- </label>
305
-
306
- {#if question.type === "TEXT"}
307
- <input
308
- class="w-full rounded-lg border border-slate-200 bg-slate-50 px-3 py-2.5 text-sm text-slate-900 transition-all duration-150 focus:border-blue-600 focus:outline-none focus:ring-3 focus:ring-blue-600/10"
309
- type="text"
310
- value={String(form.answers[question.id] || "")}
311
- oninput={(e) => updateAnswer(question.id, e.currentTarget.value)}
312
- />
313
- {:else if question.type === "TEXTAREA"}
314
- <textarea
315
- class="min-h-[100px] w-full resize-y rounded-lg border border-slate-200 bg-slate-50 px-3 py-2.5 text-sm text-slate-900 transition-all duration-150 focus:border-blue-600 focus:outline-none focus:ring-3 focus:ring-blue-600/10"
316
- value={String(form.answers[question.id] || "")}
317
- oninput={(e) => updateAnswer(question.id, e.currentTarget.value)}
318
- ></textarea>
319
- {:else if question.type === "DATE"}
320
- <input
321
- class="w-full rounded-lg border border-slate-200 bg-slate-50 px-3 py-2.5 text-sm text-slate-900 transition-all duration-150 focus:border-blue-600 focus:outline-none focus:ring-3 focus:ring-blue-600/10"
322
- type="date"
323
- value={String(form.answers[question.id] || "")}
324
- oninput={(e) => updateAnswer(question.id, e.currentTarget.value)}
325
- />
326
- {:else if question.type === "SELECT"}
327
- <select
328
- class="w-full rounded-lg border border-slate-200 bg-slate-50 px-3 py-2.5 text-sm text-slate-900 transition-all duration-150 focus:border-blue-600 focus:outline-none focus:ring-3 focus:ring-blue-600/10"
329
- value={String(form.answers[question.id] || "")}
330
- onchange={(e) => updateAnswer(question.id, e.currentTarget.value)}
331
- >
332
- <option value="">Select an option</option>
333
- {#each question.options || [] as option (option.id)}
334
- <option value={option.value}>{option.label}</option>
335
- {/each}
336
- </select>
337
- {:else if question.type === "CHECKBOX"}
338
- <div class="space-y-2 pt-1">
339
- {#each question.options || [] as option (option.id)}
340
- <label class="flex items-center gap-2.5 text-sm text-slate-900">
341
- <input
342
- type="checkbox"
343
- class="h-4 w-4 rounded accent-blue-600"
344
- checked={Array.isArray(form.answers[question.id]) && (form.answers[question.id] as string[]).includes(option.value)}
345
- onchange={(e) => toggleCheckboxValue(question.id, option.value, e.currentTarget.checked)}
346
- />
347
- <span>{option.label}</span>
348
- </label>
349
- {/each}
350
- </div>
351
- {:else if question.type === "FILE"}
352
- {#if form.answers[question.id] instanceof File}
353
- <div class="flex flex-wrap gap-1.5">
354
- <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">
355
- {(form.answers[question.id] as File).name}
356
- <button
357
- type="button"
358
- onclick={() => updateAnswer(question.id, null)}
359
- class="flex cursor-pointer items-center border-none bg-transparent p-0 text-slate-500 transition-colors hover:text-red-600"
360
- aria-label="Remove file"
361
- >
362
- <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>
363
- </button>
364
- </span>
365
- </div>
366
- {:else}
367
- <input
368
- class="w-full rounded-lg border border-slate-200 bg-slate-50 px-3 py-2.5 text-sm text-slate-900 transition-all duration-150 focus:border-blue-600 focus:outline-none focus:ring-3 focus:ring-blue-600/10"
369
- type="file"
370
- accept="image/jpeg,image/png,image/gif,image/webp"
371
- onchange={(e) => {
372
- const input = e.currentTarget as HTMLInputElement;
373
- const file = input.files?.[0] || null;
374
- if (file && !["image/png","image/jpeg","image/gif","image/webp"].includes(file.type)) {
375
- input.value = "";
376
- return;
377
- }
378
- updateAnswer(question.id, file);
379
- }}
380
- />
381
- {/if}
382
- {/if}
383
- </div>
384
- {/each}
385
-
386
- <!-- Actions -->
387
- <div class="flex items-center gap-3 pt-2">
388
- <button
389
- class="inline-flex flex-1 items-center justify-center gap-2 rounded-lg border-none bg-blue-600 px-5 py-2.5 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"
390
- type="submit"
391
- disabled={isSubmitting}
392
- >
393
- {#if isSubmitting}
394
- <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="animate-spin"><path d="M21 12a9 9 0 1 1-6.219-8.56" /></svg>
395
- Submitting...
396
- {:else}
397
- Submit
398
- {/if}
399
- </button>
400
- </div>
401
- </form>
402
- </div>
403
- {/if}
404
- </div>
405
- </div>
406
- {/if}
1
+ <script lang="ts">
2
+ import { cn, createTicket, initialFormValues, type Template, type FormValues } from "vicket";
3
+
4
+ let {
5
+ open,
6
+ onclose,
7
+ templates,
8
+ }: {
9
+ open: boolean;
10
+ onclose: () => void;
11
+ templates: Template[];
12
+ } = $props();
13
+
14
+ /* -- Internal state -- */
15
+ let step = $state<"identify" | "details" | "success">("identify");
16
+ let selectedTemplateId = $state("");
17
+ let form = $state<FormValues>({ ...initialFormValues });
18
+ let isSubmitting = $state(false);
19
+ let dialogError = $state("");
20
+ let emailLimitReached = $state(false);
21
+
22
+ /* -- Derived -- */
23
+ let selectedTemplate = $derived(templates.find((t) => t.id === selectedTemplateId) || null);
24
+ let orderedQuestions = $derived(
25
+ [...(selectedTemplate?.questions || [])].sort((a, b) => a.order - b.order),
26
+ );
27
+ let canContinue = $derived(form.email.trim().length > 0);
28
+
29
+ /* -- Sync selectedTemplateId when templates changes -- */
30
+ $effect(() => {
31
+ if (templates.length > 0 && !selectedTemplateId) {
32
+ selectedTemplateId = templates[0].id;
33
+ }
34
+ });
35
+
36
+ /* -- Reset when dialog opens -- */
37
+ $effect(() => {
38
+ if (open) {
39
+ step = "identify";
40
+ form = { ...initialFormValues };
41
+ dialogError = "";
42
+ emailLimitReached = false;
43
+ selectedTemplateId = templates.length > 0 ? templates[0].id : "";
44
+ }
45
+ });
46
+
47
+ /* -- Functions -- */
48
+ function resetAndClose() {
49
+ step = "identify";
50
+ form = { ...initialFormValues };
51
+ dialogError = "";
52
+ selectedTemplateId = templates.length > 0 ? templates[0].id : "";
53
+ onclose();
54
+ }
55
+
56
+ function updateAnswer(questionId: string, value: unknown) {
57
+ form = { ...form, answers: { ...form.answers, [questionId]: value } };
58
+ }
59
+
60
+ function toggleCheckboxValue(questionId: string, value: string, checked: boolean) {
61
+ const current = Array.isArray(form.answers[questionId])
62
+ ? (form.answers[questionId] as string[])
63
+ : [];
64
+ const next = checked
65
+ ? [...new Set([...current, value])]
66
+ : current.filter((item) => item !== value);
67
+ updateAnswer(questionId, next);
68
+ }
69
+
70
+ function validateRequired(): string {
71
+ if (!selectedTemplate) return "Please select a template.";
72
+ if (!form.email.trim()) return "Email is required.";
73
+ if (!form.title.trim()) return "Subject is required.";
74
+
75
+ for (const question of orderedQuestions) {
76
+ if (!question.required) continue;
77
+ const value = form.answers[question.id];
78
+
79
+ if (question.type === "CHECKBOX") {
80
+ if (!Array.isArray(value) || value.length === 0)
81
+ return `"${question.label}" is required.`;
82
+ continue;
83
+ }
84
+ if (question.type === "FILE") {
85
+ if (!(value instanceof File)) return `"${question.label}" is required.`;
86
+ continue;
87
+ }
88
+ if (value === null || value === undefined || String(value).trim() === "") {
89
+ return `"${question.label}" is required.`;
90
+ }
91
+ }
92
+ return "";
93
+ }
94
+
95
+ async function handleSubmit(event: SubmitEvent) {
96
+ event.preventDefault();
97
+ dialogError = "";
98
+
99
+ const validationError = validateRequired();
100
+ if (validationError) {
101
+ dialogError = validationError;
102
+ return;
103
+ }
104
+ if (!selectedTemplate) {
105
+ dialogError = "Template is required.";
106
+ return;
107
+ }
108
+
109
+ isSubmitting = true;
110
+ try {
111
+ const fileQuestionIds = orderedQuestions
112
+ .filter((q) => q.type === "FILE" && form.answers[q.id] instanceof File)
113
+ .map((q) => q.id);
114
+
115
+ const result = await createTicket({
116
+ email: form.email.trim(),
117
+ title: form.title.trim(),
118
+ templateId: selectedTemplate.id,
119
+ answers: { ...form.answers },
120
+ hasFiles: fileQuestionIds.length > 0,
121
+ fileQuestionIds,
122
+ });
123
+
124
+ emailLimitReached = result.emailLimitReached ?? false;
125
+ step = "success";
126
+ } catch (submitError) {
127
+ dialogError = submitError instanceof Error ? submitError.message : "Unexpected error.";
128
+ } finally {
129
+ isSubmitting = false;
130
+ }
131
+ }
132
+ </script>
133
+
134
+ {#if open}
135
+ <!-- svelte-ignore a11y_click_events_have_key_events -->
136
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
137
+ <div class="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm" onclick={resetAndClose}>
138
+ <!-- svelte-ignore a11y_click_events_have_key_events -->
139
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
140
+ <div class="relative mx-4 w-full max-w-lg overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-xl" onclick={(e) => e.stopPropagation()}>
141
+ <!-- Close button -->
142
+ <button
143
+ type="button"
144
+ onclick={resetAndClose}
145
+ class="absolute right-4 top-4 z-10 flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg border-none bg-transparent text-slate-500 transition-colors hover:bg-slate-50 hover:text-slate-900"
146
+ aria-label="Close"
147
+ >
148
+ <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M18 6 6 18" /><path d="m6 6 12 12" /></svg>
149
+ </button>
150
+
151
+ {#if step === "success"}
152
+ <div class="px-6 py-12 text-center">
153
+ <div class="mx-auto mb-5 flex h-14 w-14 items-center justify-center rounded-full bg-green-50 text-green-600">
154
+ <svg width="28" height="28" 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>
155
+ </div>
156
+ <h2 class="m-0 text-xl font-bold text-slate-900">Ticket submitted!</h2>
157
+ {#if emailLimitReached}
158
+ <p class="mt-2 text-sm text-slate-500">
159
+ Your ticket was created, but the daily email limit for this service has been reached. No confirmation email was sent. Please consider using a self-hosted email delivery setup for unlimited emails.
160
+ </p>
161
+ {:else}
162
+ <p class="mt-2 text-sm text-slate-500">
163
+ Check your email for a secure link to follow your ticket.
164
+ </p>
165
+ {/if}
166
+ <button
167
+ type="button"
168
+ class="mt-6 inline-flex items-center gap-2 !rounded-full border border-slate-200 bg-white !px-7 !py-3 text-sm font-semibold text-slate-900 cursor-pointer transition-all hover:border-slate-300 hover:bg-slate-50"
169
+ onclick={resetAndClose}
170
+ >
171
+ Close
172
+ </button>
173
+ </div>
174
+ {:else if step === "identify"}
175
+ <div class="px-6 py-6">
176
+ <h2 class="m-0 text-lg font-bold text-slate-900">Submit a request</h2>
177
+ <p class="mt-1 text-sm text-slate-500">We'll get back to you as soon as possible.</p>
178
+ <div class="mt-5 space-y-5">
179
+ {#if dialogError}
180
+ <div class={cn("flex items-start gap-3 rounded-xl border p-4 text-sm", "border-red-200 bg-red-50 text-red-900")} role="alert">
181
+ <span class="mt-0.5 shrink-0">
182
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="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>
183
+ </span>
184
+ <span class="flex-1">{dialogError}</span>
185
+ <button type="button" onclick={() => (dialogError = "")} class="shrink-0 cursor-pointer border-none bg-transparent p-0 opacity-50 transition-opacity hover:opacity-100" aria-label="Dismiss">
186
+ <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>
187
+ </button>
188
+ </div>
189
+ {/if}
190
+
191
+ <!-- Email -->
192
+ <div class="space-y-1.5">
193
+ <label class="text-sm font-semibold text-slate-900" for="vk-dialog-email">Email<span class="text-red-600"> *</span></label>
194
+ <div class="relative">
195
+ <span class="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-slate-500">
196
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><rect width="20" height="16" x="2" y="4" rx="2" /><path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7" /></svg>
197
+ </span>
198
+ <input
199
+ id="vk-dialog-email"
200
+ class="w-full rounded-lg border border-slate-200 bg-slate-50 py-2.5 pl-10 pr-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"
201
+ type="email"
202
+ placeholder="you@example.com"
203
+ bind:value={form.email}
204
+ required
205
+ />
206
+ </div>
207
+ <p class="text-xs text-slate-500">We'll contact you at this address.</p>
208
+ </div>
209
+
210
+ <!-- Template selector -->
211
+ {#if templates.length > 1}
212
+ <div class="space-y-2">
213
+ <p class="m-0 text-sm font-semibold text-slate-900">What can we help you with?</p>
214
+ <div class="grid gap-2">
215
+ {#each templates as template (template.id)}
216
+ <button
217
+ type="button"
218
+ class={cn(
219
+ "flex w-full cursor-pointer items-start gap-3 rounded-xl border border-slate-200 bg-slate-50 p-3.5 text-left transition-all duration-150 hover:border-slate-300",
220
+ selectedTemplateId === template.id && "!border-blue-600 !bg-blue-50 ring-3 ring-blue-600/12",
221
+ )}
222
+ onclick={() => (selectedTemplateId = template.id)}
223
+ >
224
+ <span class={cn(
225
+ "mt-0.5 flex h-4 w-4 shrink-0 items-center justify-center rounded-full border-2 transition-colors",
226
+ selectedTemplateId === template.id
227
+ ? "border-blue-600 bg-blue-600"
228
+ : "border-slate-300 bg-transparent",
229
+ )}>
230
+ {#if selectedTemplateId === template.id}
231
+ <span class="block h-1.5 w-1.5 rounded-full bg-white"></span>
232
+ {/if}
233
+ </span>
234
+ <div>
235
+ <span class="block text-sm font-semibold text-slate-900">{template.name}</span>
236
+ {#if template.description}
237
+ <span class="mt-0.5 block text-xs text-slate-500">{template.description}</span>
238
+ {/if}
239
+ </div>
240
+ </button>
241
+ {/each}
242
+ </div>
243
+ </div>
244
+ {/if}
245
+
246
+ <!-- Continue -->
247
+ <button
248
+ type="button"
249
+ class="inline-flex w-full items-center justify-center gap-2 rounded-lg border-none bg-blue-600 px-5 py-2.5 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"
250
+ disabled={!canContinue}
251
+ onclick={() => { dialogError = ""; step = "details"; }}
252
+ >
253
+ Continue
254
+ </button>
255
+ </div>
256
+ </div>
257
+ {:else if step === "details"}
258
+ <div class="px-6 py-6">
259
+ <button
260
+ type="button"
261
+ class="-ml-2 -mt-1 mb-3 inline-flex cursor-pointer items-center gap-1 rounded-lg border-none bg-transparent px-2 py-1 text-sm font-medium text-slate-500 transition-colors hover:bg-slate-50 hover:text-slate-900"
262
+ onclick={() => { dialogError = ""; step = "identify"; }}
263
+ >
264
+ <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="m12 19-7-7 7-7" /><path d="M19 12H5" /></svg>
265
+ Back
266
+ </button>
267
+
268
+ <h2 class="m-0 text-lg font-bold text-slate-900">
269
+ {selectedTemplate?.name || "Ticket details"}
270
+ </h2>
271
+
272
+ <form class="mt-5 space-y-4" onsubmit={handleSubmit}>
273
+ {#if dialogError}
274
+ <div class={cn("flex items-start gap-3 rounded-xl border p-4 text-sm", "border-red-200 bg-red-50 text-red-900")} role="alert">
275
+ <span class="mt-0.5 shrink-0">
276
+ <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>
277
+ </span>
278
+ <span class="flex-1">{dialogError}</span>
279
+ <button type="button" onclick={() => (dialogError = "")} class="shrink-0 cursor-pointer border-none bg-transparent p-0 opacity-50 transition-opacity hover:opacity-100" aria-label="Dismiss">
280
+ <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>
281
+ </button>
282
+ </div>
283
+ {/if}
284
+
285
+ <!-- Subject -->
286
+ <div class="space-y-1.5">
287
+ <label class="text-sm font-semibold text-slate-900" for="vk-dialog-subject">Subject<span class="text-red-600"> *</span></label>
288
+ <input
289
+ id="vk-dialog-subject"
290
+ class="w-full rounded-lg border border-slate-200 bg-slate-50 px-3 py-2.5 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"
291
+ type="text"
292
+ placeholder="Brief description of your issue"
293
+ bind:value={form.title}
294
+ required
295
+ />
296
+ </div>
297
+
298
+ <!-- Dynamic questions -->
299
+ {#each orderedQuestions as question (question.id)}
300
+ <div class="space-y-1.5">
301
+ <label class="text-sm font-semibold text-slate-900">
302
+ {question.label}{#if question.required}<span class="text-red-600"> *</span>{/if}
303
+ </label>
304
+
305
+ {#if question.type === "TEXT"}
306
+ <input
307
+ class="w-full rounded-lg border border-slate-200 bg-slate-50 px-3 py-2.5 text-sm text-slate-900 transition-all duration-150 focus:border-blue-600 focus:outline-none focus:ring-3 focus:ring-blue-600/10"
308
+ type="text"
309
+ value={String(form.answers[question.id] || "")}
310
+ oninput={(e) => updateAnswer(question.id, e.currentTarget.value)}
311
+ />
312
+ {:else if question.type === "TEXTAREA"}
313
+ <textarea
314
+ class="min-h-[100px] w-full resize-y rounded-lg border border-slate-200 bg-slate-50 px-3 py-2.5 text-sm text-slate-900 transition-all duration-150 focus:border-blue-600 focus:outline-none focus:ring-3 focus:ring-blue-600/10"
315
+ value={String(form.answers[question.id] || "")}
316
+ oninput={(e) => updateAnswer(question.id, e.currentTarget.value)}
317
+ ></textarea>
318
+ {:else if question.type === "DATE"}
319
+ <input
320
+ class="w-full rounded-lg border border-slate-200 bg-slate-50 px-3 py-2.5 text-sm text-slate-900 transition-all duration-150 focus:border-blue-600 focus:outline-none focus:ring-3 focus:ring-blue-600/10"
321
+ type="date"
322
+ value={String(form.answers[question.id] || "")}
323
+ oninput={(e) => updateAnswer(question.id, e.currentTarget.value)}
324
+ />
325
+ {:else if question.type === "SELECT"}
326
+ <select
327
+ class="w-full rounded-lg border border-slate-200 bg-slate-50 px-3 py-2.5 text-sm text-slate-900 transition-all duration-150 focus:border-blue-600 focus:outline-none focus:ring-3 focus:ring-blue-600/10"
328
+ value={String(form.answers[question.id] || "")}
329
+ onchange={(e) => updateAnswer(question.id, e.currentTarget.value)}
330
+ >
331
+ <option value="">Select an option</option>
332
+ {#each question.options || [] as option (option.id)}
333
+ <option value={option.value}>{option.label}</option>
334
+ {/each}
335
+ </select>
336
+ {:else if question.type === "CHECKBOX"}
337
+ <div class="space-y-2 pt-1">
338
+ {#each question.options || [] as option (option.id)}
339
+ <label class="flex items-center gap-2.5 text-sm text-slate-900">
340
+ <input
341
+ type="checkbox"
342
+ class="h-4 w-4 rounded accent-blue-600"
343
+ checked={Array.isArray(form.answers[question.id]) && (form.answers[question.id] as string[]).includes(option.value)}
344
+ onchange={(e) => toggleCheckboxValue(question.id, option.value, e.currentTarget.checked)}
345
+ />
346
+ <span>{option.label}</span>
347
+ </label>
348
+ {/each}
349
+ </div>
350
+ {:else if question.type === "FILE"}
351
+ {#if form.answers[question.id] instanceof File}
352
+ <div class="flex flex-wrap gap-1.5">
353
+ <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">
354
+ {(form.answers[question.id] as File).name}
355
+ <button
356
+ type="button"
357
+ onclick={() => updateAnswer(question.id, null)}
358
+ class="flex cursor-pointer items-center border-none bg-transparent p-0 text-slate-500 transition-colors hover:text-red-600"
359
+ aria-label="Remove file"
360
+ >
361
+ <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>
362
+ </button>
363
+ </span>
364
+ </div>
365
+ {:else}
366
+ <input
367
+ class="w-full rounded-lg border border-slate-200 bg-slate-50 px-3 py-2.5 text-sm text-slate-900 transition-all duration-150 focus:border-blue-600 focus:outline-none focus:ring-3 focus:ring-blue-600/10"
368
+ type="file"
369
+ accept="image/jpeg,image/png,image/gif,image/webp"
370
+ onchange={(e) => {
371
+ const input = e.currentTarget as HTMLInputElement;
372
+ const file = input.files?.[0] || null;
373
+ if (file && !["image/png","image/jpeg","image/gif","image/webp"].includes(file.type)) {
374
+ input.value = "";
375
+ return;
376
+ }
377
+ updateAnswer(question.id, file);
378
+ }}
379
+ />
380
+ {/if}
381
+ {/if}
382
+ </div>
383
+ {/each}
384
+
385
+ <!-- Actions -->
386
+ <div class="flex items-center gap-3 pt-2">
387
+ <button
388
+ class="inline-flex flex-1 items-center justify-center gap-2 rounded-lg border-none bg-blue-600 px-5 py-2.5 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"
389
+ type="submit"
390
+ disabled={isSubmitting}
391
+ >
392
+ {#if isSubmitting}
393
+ <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="animate-spin"><path d="M21 12a9 9 0 1 1-6.219-8.56" /></svg>
394
+ Submitting...
395
+ {:else}
396
+ Submit
397
+ {/if}
398
+ </button>
399
+ </div>
400
+ </form>
401
+ </div>
402
+ {/if}
403
+ </div>
404
+ </div>
405
+ {/if}