@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,459 +1,457 @@
1
- <script lang="ts">
2
- import type { Template, FormValues } from "$lib/vicket/types";
3
- import { cn } from "$lib/vicket/utils";
4
- import { initialFormValues, createTicket } from "$lib/vicket/api";
5
-
6
- const MAX_FILES = 3;
7
- const MAX_FILE_SIZE = 2 * 1024 * 1024; // 2MB
8
-
9
- let { open, onclose, templates }: { open: boolean; onclose: () => void; templates: Template[] } = $props();
10
-
11
- /* -- Internal state --------------------------- */
12
- let step = $state<"identify" | "details" | "success">("identify");
13
- let selectedTemplateId = $state("");
14
- let form = $state<FormValues>({ ...initialFormValues });
15
- let isSubmitting = $state(false);
16
- let dialogError = $state("");
17
- let emailLimitReached = $state(false);
18
-
19
- /* -- Derived ---------------------------------- */
20
- let selectedTemplate = $derived(templates.find((t) => t.id === selectedTemplateId) || null);
21
- let orderedQuestions = $derived(
22
- [...(selectedTemplate?.questions || [])].sort((a, b) => a.order - b.order),
23
- );
24
- let canContinue = $derived(form.email.trim().length > 0);
25
-
26
- /* -- Sync default template when templates change -- */
27
- $effect(() => {
28
- if (templates.length > 0 && !selectedTemplateId) {
29
- selectedTemplateId = templates[0].id;
30
- }
31
- });
32
-
33
- /* -- Functions -------------------------------- */
34
- function resetAndClose() {
35
- step = "identify";
36
- form = { ...initialFormValues };
37
- dialogError = "";
38
- selectedTemplateId = templates.length > 0 ? templates[0].id : "";
39
- onclose();
40
- }
41
-
42
- function updateAnswer(questionId: string, value: unknown) {
43
- form = { ...form, answers: { ...form.answers, [questionId]: value } };
44
- }
45
-
46
- let fileInputRefs: Record<string, HTMLInputElement | null> = $state({});
47
-
48
- function getFileArray(questionId: string): File[] {
49
- const val = form.answers[questionId];
50
- if (Array.isArray(val)) return val as File[];
51
- if (val instanceof File) return [val];
52
- return [];
53
- }
54
-
55
- const ALLOWED_TYPES = ["image/png", "image/jpeg", "image/gif", "image/webp"];
56
-
57
- function handleFileAdd(questionId: string, event: Event) {
58
- const input = event.currentTarget as HTMLInputElement;
59
- const newFiles = input.files;
60
- if (!newFiles) return;
61
- const current = getFileArray(questionId);
62
- const remaining = MAX_FILES - current.length;
63
- if (remaining <= 0) return;
64
- const accepted: File[] = [];
65
- for (let i = 0; i < Math.min(newFiles.length, remaining); i++) {
66
- if (!ALLOWED_TYPES.includes(newFiles[i].type)) continue;
67
- if (newFiles[i].size > MAX_FILE_SIZE) continue;
68
- accepted.push(newFiles[i]);
69
- }
70
- if (accepted.length > 0) updateAnswer(questionId, [...current, ...accepted]);
71
- input.value = "";
72
- }
73
-
74
- function removeFileAt(questionId: string, index: number) {
75
- const current = getFileArray(questionId);
76
- const next = current.filter((_, i) => i !== index);
77
- updateAnswer(questionId, next.length > 0 ? next : null);
78
- }
79
-
80
- function toggleCheckboxValue(questionId: string, value: string, checked: boolean) {
81
- const current = Array.isArray(form.answers[questionId])
82
- ? (form.answers[questionId] as string[])
83
- : [];
84
- const next = checked
85
- ? [...new Set([...current, value])]
86
- : current.filter((item) => item !== value);
87
- updateAnswer(questionId, next);
88
- }
89
-
90
- function validateRequired(): string {
91
- if (!selectedTemplate) return "Please select a template.";
92
- if (!form.email.trim()) return "Email is required.";
93
- if (!form.title.trim()) return "Subject is required.";
94
-
95
- for (const question of orderedQuestions) {
96
- if (!question.required) continue;
97
- const value = form.answers[question.id];
98
-
99
- if (question.type === "CHECKBOX") {
100
- if (!Array.isArray(value) || value.length === 0)
101
- return `"${question.label}" is required.`;
102
- continue;
103
- }
104
- if (question.type === "FILE") {
105
- const hasFiles = Array.isArray(value) ? value.length > 0 : value instanceof File;
106
- if (!hasFiles) return `"${question.label}" is required.`;
107
- continue;
108
- }
109
- if (value === null || value === undefined || String(value).trim() === "") {
110
- return `"${question.label}" is required.`;
111
- }
112
- }
113
- return "";
114
- }
115
-
116
- async function handleSubmit(event: SubmitEvent) {
117
- event.preventDefault();
118
- dialogError = "";
119
-
120
- const validationError = validateRequired();
121
- if (validationError) {
122
- dialogError = validationError;
123
- return;
124
- }
125
- if (!selectedTemplate) {
126
- dialogError = "Template is required.";
127
- return;
128
- }
129
-
130
- isSubmitting = true;
131
- try {
132
- const fileQuestionIds = orderedQuestions
133
- .filter((q) => {
134
- const val = form.answers[q.id];
135
- return q.type === "FILE" && (val instanceof File || (Array.isArray(val) && val.length > 0));
136
- })
137
- .map((q) => q.id);
138
-
139
- const result = await createTicket({
140
- email: form.email.trim(),
141
- title: form.title.trim(),
142
- templateId: selectedTemplate.id,
143
- answers: { ...form.answers },
144
- hasFiles: fileQuestionIds.length > 0,
145
- fileQuestionIds,
146
- });
147
-
148
- emailLimitReached = result.emailLimitReached ?? false;
149
- step = "success";
150
- } catch (submitError) {
151
- dialogError = submitError instanceof Error ? submitError.message : "Unexpected error.";
152
- } finally {
153
- isSubmitting = false;
154
- }
155
- }
156
- </script>
157
-
158
- {#if open}
159
- <div class="vk-dialog-overlay" onclick={resetAndClose}>
160
- <!-- svelte-ignore a11y_click_events_have_key_events -->
161
- <!-- svelte-ignore a11y_no_static_element_interactions -->
162
- <div class="vk-dialog vk-slide-up" onclick={(e: MouseEvent) => e.stopPropagation()}>
163
- <button
164
- type="button"
165
- onclick={resetAndClose}
166
- class="vk-dialog-close"
167
- aria-label="Close"
168
- >
169
- &#10005;
170
- </button>
171
-
172
- <!-- Success state -->
173
- {#if step === "success"}
174
- <div class="vk-dialog-body vk-slide-up">
175
- <div class="vk-success-icon">
176
- <span>&#10003;</span>
177
- </div>
178
- <div class="vk-empty-state">
179
- <h2 class="vk-dialog-title">Ticket submitted!</h2>
180
- {#if emailLimitReached}
181
- <p class="vk-empty-text">
182
- 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.
183
- </p>
184
- {:else}
185
- <p class="vk-empty-text">
186
- Check your email for a secure link to follow your ticket.
187
- </p>
188
- {/if}
189
- <button
190
- type="button"
191
- class="vk-button pill"
192
- onclick={resetAndClose}
193
- >
194
- Close
195
- </button>
196
- </div>
197
- </div>
198
- {/if}
199
-
200
- <!-- Step 1: Identify -->
201
- {#if step === "identify"}
202
- <div class="vk-dialog-body">
203
- <h2 class="vk-dialog-title">Submit a request</h2>
204
- <p class="vk-dialog-subtitle">
205
- We'll get back to you as soon as possible.
206
- </p>
207
-
208
- <div class="vk-stack">
209
- {#if dialogError}
210
- <div class={cn("vk-alert vk-slide-up", "error")} role="alert">
211
- <span>&#9888;</span>
212
- <span>{dialogError}</span>
213
- <button
214
- type="button"
215
- onclick={() => (dialogError = "")}
216
- class="vk-alert-dismiss"
217
- aria-label="Dismiss"
218
- >
219
- &#10005;
220
- </button>
221
- </div>
222
- {/if}
223
-
224
- <!-- Email -->
225
- <div class="vk-field">
226
- <label class="vk-label required" for="vk-dialog-email">
227
- Email
228
- </label>
229
- <div class="vk-input-icon-wrap">
230
- <span class="vk-input-icon">&#9993;</span>
231
- <input
232
- id="vk-dialog-email"
233
- class="vk-input"
234
- type="email"
235
- placeholder="you@example.com"
236
- bind:value={form.email}
237
- required
238
- />
239
- </div>
240
- <p class="vk-hint">We'll contact you at this address.</p>
241
- </div>
242
-
243
- <!-- Template selector -->
244
- {#if templates.length > 1}
245
- <div class="vk-field">
246
- <p class="vk-label">What can we help you with?</p>
247
- <div class="vk-grid">
248
- {#each templates as template (template.id)}
249
- <button
250
- type="button"
251
- class={cn(
252
- "vk-radio-card",
253
- selectedTemplateId === template.id && "active",
254
- )}
255
- onclick={() => (selectedTemplateId = template.id)}
256
- >
257
- <span class="vk-radio-dot">
258
- <span class="vk-radio-dot-inner"></span>
259
- </span>
260
- <div>
261
- <span class="vk-radio-name">{template.name}</span>
262
- {#if template.description}
263
- <span class="vk-radio-description">
264
- {template.description}
265
- </span>
266
- {/if}
267
- </div>
268
- </button>
269
- {/each}
270
- </div>
271
- </div>
272
- {/if}
273
-
274
- <!-- Continue -->
275
- <button
276
- type="button"
277
- class="vk-button primary full"
278
- disabled={!canContinue}
279
- onclick={() => {
280
- dialogError = "";
281
- step = "details";
282
- }}
283
- >
284
- Continue
285
- </button>
286
- </div>
287
- </div>
288
- {/if}
289
-
290
- <!-- Step 2: Details -->
291
- {#if step === "details"}
292
- <div class="vk-dialog-body">
293
- <button
294
- type="button"
295
- class="vk-button ghost"
296
- onclick={() => {
297
- dialogError = "";
298
- step = "identify";
299
- }}
300
- >
301
- &#8592; Back
302
- </button>
303
-
304
- <h2 class="vk-dialog-title">
305
- {selectedTemplate?.name || "Ticket details"}
306
- </h2>
307
-
308
- <form class="vk-stack" onsubmit={handleSubmit}>
309
- {#if dialogError}
310
- <div class={cn("vk-alert vk-slide-up", "error")} role="alert">
311
- <span>&#9888;</span>
312
- <span>{dialogError}</span>
313
- <button
314
- type="button"
315
- onclick={() => (dialogError = "")}
316
- class="vk-alert-dismiss"
317
- aria-label="Dismiss"
318
- >
319
- &#10005;
320
- </button>
321
- </div>
322
- {/if}
323
-
324
- <!-- Subject -->
325
- <div class="vk-field">
326
- <label class="vk-label required" for="vk-dialog-subject">
327
- Subject
328
- </label>
329
- <input
330
- id="vk-dialog-subject"
331
- class="vk-input"
332
- type="text"
333
- placeholder="Brief description of your issue"
334
- bind:value={form.title}
335
- required
336
- />
337
- </div>
338
-
339
- <!-- Dynamic questions -->
340
- {#each orderedQuestions as question (question.id)}
341
- <div class="vk-field">
342
- <label
343
- class={cn("vk-label", question.required && "required")}
344
- >
345
- {question.label}
346
- </label>
347
-
348
- {#if question.type === "TEXT"}
349
- <input
350
- class="vk-input"
351
- type="text"
352
- value={String(form.answers[question.id] || "")}
353
- oninput={(e: Event) => updateAnswer(question.id, (e.currentTarget as HTMLInputElement).value)}
354
- />
355
- {:else if question.type === "TEXTAREA"}
356
- <textarea
357
- class="vk-textarea"
358
- value={String(form.answers[question.id] || "")}
359
- oninput={(e: Event) => updateAnswer(question.id, (e.currentTarget as HTMLTextAreaElement).value)}
360
- ></textarea>
361
- {:else if question.type === "DATE"}
362
- <input
363
- class="vk-input"
364
- type="date"
365
- value={String(form.answers[question.id] || "")}
366
- oninput={(e: Event) => updateAnswer(question.id, (e.currentTarget as HTMLInputElement).value)}
367
- />
368
- {:else if question.type === "SELECT"}
369
- <select
370
- class="vk-select"
371
- value={String(form.answers[question.id] || "")}
372
- onchange={(e: Event) => updateAnswer(question.id, (e.currentTarget as HTMLSelectElement).value)}
373
- >
374
- <option value="">Select an option</option>
375
- {#each question.options || [] as option (option.id)}
376
- <option value={option.value}>{option.label}</option>
377
- {/each}
378
- </select>
379
- {:else if question.type === "CHECKBOX"}
380
- <div class="vk-checkbox-list">
381
- {#each question.options || [] as option (option.id)}
382
- <label class="vk-checkbox-item">
383
- <input
384
- type="checkbox"
385
- checked={Array.isArray(form.answers[question.id]) &&
386
- (form.answers[question.id] as string[]).includes(option.value)}
387
- onchange={(e: Event) =>
388
- toggleCheckboxValue(
389
- question.id,
390
- option.value,
391
- (e.currentTarget as HTMLInputElement).checked,
392
- )}
393
- />
394
- <span>{option.label}</span>
395
- </label>
396
- {/each}
397
- </div>
398
- {:else if question.type === "FILE"}
399
- {#if getFileArray(question.id).length > 0}
400
- <div class="vk-image-grid">
401
- {#each getFileArray(question.id) as file, idx (`${file.name}-${idx}`)}
402
- <div class="vk-image-thumb">
403
- <img src={URL.createObjectURL(file)} alt={file.name} />
404
- <button
405
- type="button"
406
- class="vk-image-thumb-remove"
407
- onclick={() => removeFileAt(question.id, idx)}
408
- aria-label="Remove"
409
- >
410
- &#10005;
411
- </button>
412
- <span class="vk-image-thumb-name">{file.name}</span>
413
- </div>
414
- {/each}
415
- </div>
416
- {/if}
417
- {#if getFileArray(question.id).length < MAX_FILES}
418
- <input
419
- bind:this={fileInputRefs[question.id]}
420
- type="file"
421
- accept="image/jpeg,image/png,image/gif,image/webp"
422
- multiple
423
- style="display: none"
424
- onchange={(e: Event) => handleFileAdd(question.id, e)}
425
- />
426
- <button
427
- type="button"
428
- class="vk-add-image-btn"
429
- onclick={() => fileInputRefs[question.id]?.click()}
430
- >
431
- &#128247;
432
- {getFileArray(question.id).length === 0
433
- ? `Add images (up to ${MAX_FILES})`
434
- : `Add more (${MAX_FILES - getFileArray(question.id).length} remaining)`}
435
- </button>
436
- {/if}
437
- {/if}
438
- </div>
439
- {/each}
440
-
441
- <!-- Actions -->
442
- <button
443
- class="vk-button primary full"
444
- type="submit"
445
- disabled={isSubmitting}
446
- >
447
- {#if isSubmitting}
448
- <span class="vk-spinner"></span>
449
- Submitting...
450
- {:else}
451
- Submit
452
- {/if}
453
- </button>
454
- </form>
455
- </div>
456
- {/if}
457
- </div>
458
- </div>
459
- {/if}
1
+ <script lang="ts">
2
+ import { cn, createTicket, initialFormValues, type Template, type FormValues } from "vicket";
3
+
4
+ const MAX_FILES = 3;
5
+ const MAX_FILE_SIZE = 2 * 1024 * 1024; // 2MB
6
+
7
+ let { open, onclose, templates }: { open: boolean; onclose: () => void; templates: Template[] } = $props();
8
+
9
+ /* -- Internal state --------------------------- */
10
+ let step = $state<"identify" | "details" | "success">("identify");
11
+ let selectedTemplateId = $state("");
12
+ let form = $state<FormValues>({ ...initialFormValues });
13
+ let isSubmitting = $state(false);
14
+ let dialogError = $state("");
15
+ let emailLimitReached = $state(false);
16
+
17
+ /* -- Derived ---------------------------------- */
18
+ let selectedTemplate = $derived(templates.find((t) => t.id === selectedTemplateId) || null);
19
+ let orderedQuestions = $derived(
20
+ [...(selectedTemplate?.questions || [])].sort((a, b) => a.order - b.order),
21
+ );
22
+ let canContinue = $derived(form.email.trim().length > 0);
23
+
24
+ /* -- Sync default template when templates change -- */
25
+ $effect(() => {
26
+ if (templates.length > 0 && !selectedTemplateId) {
27
+ selectedTemplateId = templates[0].id;
28
+ }
29
+ });
30
+
31
+ /* -- Functions -------------------------------- */
32
+ function resetAndClose() {
33
+ step = "identify";
34
+ form = { ...initialFormValues };
35
+ dialogError = "";
36
+ selectedTemplateId = templates.length > 0 ? templates[0].id : "";
37
+ onclose();
38
+ }
39
+
40
+ function updateAnswer(questionId: string, value: unknown) {
41
+ form = { ...form, answers: { ...form.answers, [questionId]: value } };
42
+ }
43
+
44
+ let fileInputRefs: Record<string, HTMLInputElement | null> = $state({});
45
+
46
+ function getFileArray(questionId: string): File[] {
47
+ const val = form.answers[questionId];
48
+ if (Array.isArray(val)) return val as File[];
49
+ if (val instanceof File) return [val];
50
+ return [];
51
+ }
52
+
53
+ const ALLOWED_TYPES = ["image/png", "image/jpeg", "image/gif", "image/webp"];
54
+
55
+ function handleFileAdd(questionId: string, event: Event) {
56
+ const input = event.currentTarget as HTMLInputElement;
57
+ const newFiles = input.files;
58
+ if (!newFiles) return;
59
+ const current = getFileArray(questionId);
60
+ const remaining = MAX_FILES - current.length;
61
+ if (remaining <= 0) return;
62
+ const accepted: File[] = [];
63
+ for (let i = 0; i < Math.min(newFiles.length, remaining); i++) {
64
+ if (!ALLOWED_TYPES.includes(newFiles[i].type)) continue;
65
+ if (newFiles[i].size > MAX_FILE_SIZE) continue;
66
+ accepted.push(newFiles[i]);
67
+ }
68
+ if (accepted.length > 0) updateAnswer(questionId, [...current, ...accepted]);
69
+ input.value = "";
70
+ }
71
+
72
+ function removeFileAt(questionId: string, index: number) {
73
+ const current = getFileArray(questionId);
74
+ const next = current.filter((_, i) => i !== index);
75
+ updateAnswer(questionId, next.length > 0 ? next : null);
76
+ }
77
+
78
+ function toggleCheckboxValue(questionId: string, value: string, checked: boolean) {
79
+ const current = Array.isArray(form.answers[questionId])
80
+ ? (form.answers[questionId] as string[])
81
+ : [];
82
+ const next = checked
83
+ ? [...new Set([...current, value])]
84
+ : current.filter((item) => item !== value);
85
+ updateAnswer(questionId, next);
86
+ }
87
+
88
+ function validateRequired(): string {
89
+ if (!selectedTemplate) return "Please select a template.";
90
+ if (!form.email.trim()) return "Email is required.";
91
+ if (!form.title.trim()) return "Subject is required.";
92
+
93
+ for (const question of orderedQuestions) {
94
+ if (!question.required) continue;
95
+ const value = form.answers[question.id];
96
+
97
+ if (question.type === "CHECKBOX") {
98
+ if (!Array.isArray(value) || value.length === 0)
99
+ return `"${question.label}" is required.`;
100
+ continue;
101
+ }
102
+ if (question.type === "FILE") {
103
+ const hasFiles = Array.isArray(value) ? value.length > 0 : value instanceof File;
104
+ if (!hasFiles) return `"${question.label}" is required.`;
105
+ continue;
106
+ }
107
+ if (value === null || value === undefined || String(value).trim() === "") {
108
+ return `"${question.label}" is required.`;
109
+ }
110
+ }
111
+ return "";
112
+ }
113
+
114
+ async function handleSubmit(event: SubmitEvent) {
115
+ event.preventDefault();
116
+ dialogError = "";
117
+
118
+ const validationError = validateRequired();
119
+ if (validationError) {
120
+ dialogError = validationError;
121
+ return;
122
+ }
123
+ if (!selectedTemplate) {
124
+ dialogError = "Template is required.";
125
+ return;
126
+ }
127
+
128
+ isSubmitting = true;
129
+ try {
130
+ const fileQuestionIds = orderedQuestions
131
+ .filter((q) => {
132
+ const val = form.answers[q.id];
133
+ return q.type === "FILE" && (val instanceof File || (Array.isArray(val) && val.length > 0));
134
+ })
135
+ .map((q) => q.id);
136
+
137
+ const result = await createTicket({
138
+ email: form.email.trim(),
139
+ title: form.title.trim(),
140
+ templateId: selectedTemplate.id,
141
+ answers: { ...form.answers },
142
+ hasFiles: fileQuestionIds.length > 0,
143
+ fileQuestionIds,
144
+ });
145
+
146
+ emailLimitReached = result.emailLimitReached ?? false;
147
+ step = "success";
148
+ } catch (submitError) {
149
+ dialogError = submitError instanceof Error ? submitError.message : "Unexpected error.";
150
+ } finally {
151
+ isSubmitting = false;
152
+ }
153
+ }
154
+ </script>
155
+
156
+ {#if open}
157
+ <div class="vk-dialog-overlay" onclick={resetAndClose}>
158
+ <!-- svelte-ignore a11y_click_events_have_key_events -->
159
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
160
+ <div class="vk-dialog vk-slide-up" onclick={(e: MouseEvent) => e.stopPropagation()}>
161
+ <button
162
+ type="button"
163
+ onclick={resetAndClose}
164
+ class="vk-dialog-close"
165
+ aria-label="Close"
166
+ >
167
+ &#10005;
168
+ </button>
169
+
170
+ <!-- Success state -->
171
+ {#if step === "success"}
172
+ <div class="vk-dialog-body vk-slide-up">
173
+ <div class="vk-success-icon">
174
+ <span>&#10003;</span>
175
+ </div>
176
+ <div class="vk-empty-state">
177
+ <h2 class="vk-dialog-title">Ticket submitted!</h2>
178
+ {#if emailLimitReached}
179
+ <p class="vk-empty-text">
180
+ 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.
181
+ </p>
182
+ {:else}
183
+ <p class="vk-empty-text">
184
+ Check your email for a secure link to follow your ticket.
185
+ </p>
186
+ {/if}
187
+ <button
188
+ type="button"
189
+ class="vk-button pill"
190
+ onclick={resetAndClose}
191
+ >
192
+ Close
193
+ </button>
194
+ </div>
195
+ </div>
196
+ {/if}
197
+
198
+ <!-- Step 1: Identify -->
199
+ {#if step === "identify"}
200
+ <div class="vk-dialog-body">
201
+ <h2 class="vk-dialog-title">Submit a request</h2>
202
+ <p class="vk-dialog-subtitle">
203
+ We'll get back to you as soon as possible.
204
+ </p>
205
+
206
+ <div class="vk-stack">
207
+ {#if dialogError}
208
+ <div class={cn("vk-alert vk-slide-up", "error")} role="alert">
209
+ <span>&#9888;</span>
210
+ <span>{dialogError}</span>
211
+ <button
212
+ type="button"
213
+ onclick={() => (dialogError = "")}
214
+ class="vk-alert-dismiss"
215
+ aria-label="Dismiss"
216
+ >
217
+ &#10005;
218
+ </button>
219
+ </div>
220
+ {/if}
221
+
222
+ <!-- Email -->
223
+ <div class="vk-field">
224
+ <label class="vk-label required" for="vk-dialog-email">
225
+ Email
226
+ </label>
227
+ <div class="vk-input-icon-wrap">
228
+ <span class="vk-input-icon">&#9993;</span>
229
+ <input
230
+ id="vk-dialog-email"
231
+ class="vk-input"
232
+ type="email"
233
+ placeholder="you@example.com"
234
+ bind:value={form.email}
235
+ required
236
+ />
237
+ </div>
238
+ <p class="vk-hint">We'll contact you at this address.</p>
239
+ </div>
240
+
241
+ <!-- Template selector -->
242
+ {#if templates.length > 1}
243
+ <div class="vk-field">
244
+ <p class="vk-label">What can we help you with?</p>
245
+ <div class="vk-grid">
246
+ {#each templates as template (template.id)}
247
+ <button
248
+ type="button"
249
+ class={cn(
250
+ "vk-radio-card",
251
+ selectedTemplateId === template.id && "active",
252
+ )}
253
+ onclick={() => (selectedTemplateId = template.id)}
254
+ >
255
+ <span class="vk-radio-dot">
256
+ <span class="vk-radio-dot-inner"></span>
257
+ </span>
258
+ <div>
259
+ <span class="vk-radio-name">{template.name}</span>
260
+ {#if template.description}
261
+ <span class="vk-radio-description">
262
+ {template.description}
263
+ </span>
264
+ {/if}
265
+ </div>
266
+ </button>
267
+ {/each}
268
+ </div>
269
+ </div>
270
+ {/if}
271
+
272
+ <!-- Continue -->
273
+ <button
274
+ type="button"
275
+ class="vk-button primary full"
276
+ disabled={!canContinue}
277
+ onclick={() => {
278
+ dialogError = "";
279
+ step = "details";
280
+ }}
281
+ >
282
+ Continue
283
+ </button>
284
+ </div>
285
+ </div>
286
+ {/if}
287
+
288
+ <!-- Step 2: Details -->
289
+ {#if step === "details"}
290
+ <div class="vk-dialog-body">
291
+ <button
292
+ type="button"
293
+ class="vk-button ghost"
294
+ onclick={() => {
295
+ dialogError = "";
296
+ step = "identify";
297
+ }}
298
+ >
299
+ &#8592; Back
300
+ </button>
301
+
302
+ <h2 class="vk-dialog-title">
303
+ {selectedTemplate?.name || "Ticket details"}
304
+ </h2>
305
+
306
+ <form class="vk-stack" onsubmit={handleSubmit}>
307
+ {#if dialogError}
308
+ <div class={cn("vk-alert vk-slide-up", "error")} role="alert">
309
+ <span>&#9888;</span>
310
+ <span>{dialogError}</span>
311
+ <button
312
+ type="button"
313
+ onclick={() => (dialogError = "")}
314
+ class="vk-alert-dismiss"
315
+ aria-label="Dismiss"
316
+ >
317
+ &#10005;
318
+ </button>
319
+ </div>
320
+ {/if}
321
+
322
+ <!-- Subject -->
323
+ <div class="vk-field">
324
+ <label class="vk-label required" for="vk-dialog-subject">
325
+ Subject
326
+ </label>
327
+ <input
328
+ id="vk-dialog-subject"
329
+ class="vk-input"
330
+ type="text"
331
+ placeholder="Brief description of your issue"
332
+ bind:value={form.title}
333
+ required
334
+ />
335
+ </div>
336
+
337
+ <!-- Dynamic questions -->
338
+ {#each orderedQuestions as question (question.id)}
339
+ <div class="vk-field">
340
+ <label
341
+ class={cn("vk-label", question.required && "required")}
342
+ >
343
+ {question.label}
344
+ </label>
345
+
346
+ {#if question.type === "TEXT"}
347
+ <input
348
+ class="vk-input"
349
+ type="text"
350
+ value={String(form.answers[question.id] || "")}
351
+ oninput={(e: Event) => updateAnswer(question.id, (e.currentTarget as HTMLInputElement).value)}
352
+ />
353
+ {:else if question.type === "TEXTAREA"}
354
+ <textarea
355
+ class="vk-textarea"
356
+ value={String(form.answers[question.id] || "")}
357
+ oninput={(e: Event) => updateAnswer(question.id, (e.currentTarget as HTMLTextAreaElement).value)}
358
+ ></textarea>
359
+ {:else if question.type === "DATE"}
360
+ <input
361
+ class="vk-input"
362
+ type="date"
363
+ value={String(form.answers[question.id] || "")}
364
+ oninput={(e: Event) => updateAnswer(question.id, (e.currentTarget as HTMLInputElement).value)}
365
+ />
366
+ {:else if question.type === "SELECT"}
367
+ <select
368
+ class="vk-select"
369
+ value={String(form.answers[question.id] || "")}
370
+ onchange={(e: Event) => updateAnswer(question.id, (e.currentTarget as HTMLSelectElement).value)}
371
+ >
372
+ <option value="">Select an option</option>
373
+ {#each question.options || [] as option (option.id)}
374
+ <option value={option.value}>{option.label}</option>
375
+ {/each}
376
+ </select>
377
+ {:else if question.type === "CHECKBOX"}
378
+ <div class="vk-checkbox-list">
379
+ {#each question.options || [] as option (option.id)}
380
+ <label class="vk-checkbox-item">
381
+ <input
382
+ type="checkbox"
383
+ checked={Array.isArray(form.answers[question.id]) &&
384
+ (form.answers[question.id] as string[]).includes(option.value)}
385
+ onchange={(e: Event) =>
386
+ toggleCheckboxValue(
387
+ question.id,
388
+ option.value,
389
+ (e.currentTarget as HTMLInputElement).checked,
390
+ )}
391
+ />
392
+ <span>{option.label}</span>
393
+ </label>
394
+ {/each}
395
+ </div>
396
+ {:else if question.type === "FILE"}
397
+ {#if getFileArray(question.id).length > 0}
398
+ <div class="vk-image-grid">
399
+ {#each getFileArray(question.id) as file, idx (`${file.name}-${idx}`)}
400
+ <div class="vk-image-thumb">
401
+ <img src={URL.createObjectURL(file)} alt={file.name} />
402
+ <button
403
+ type="button"
404
+ class="vk-image-thumb-remove"
405
+ onclick={() => removeFileAt(question.id, idx)}
406
+ aria-label="Remove"
407
+ >
408
+ &#10005;
409
+ </button>
410
+ <span class="vk-image-thumb-name">{file.name}</span>
411
+ </div>
412
+ {/each}
413
+ </div>
414
+ {/if}
415
+ {#if getFileArray(question.id).length < MAX_FILES}
416
+ <input
417
+ bind:this={fileInputRefs[question.id]}
418
+ type="file"
419
+ accept="image/jpeg,image/png,image/gif,image/webp"
420
+ multiple
421
+ style="display: none"
422
+ onchange={(e: Event) => handleFileAdd(question.id, e)}
423
+ />
424
+ <button
425
+ type="button"
426
+ class="vk-add-image-btn"
427
+ onclick={() => fileInputRefs[question.id]?.click()}
428
+ >
429
+ &#128247;
430
+ {getFileArray(question.id).length === 0
431
+ ? `Add images (up to ${MAX_FILES})`
432
+ : `Add more (${MAX_FILES - getFileArray(question.id).length} remaining)`}
433
+ </button>
434
+ {/if}
435
+ {/if}
436
+ </div>
437
+ {/each}
438
+
439
+ <!-- Actions -->
440
+ <button
441
+ class="vk-button primary full"
442
+ type="submit"
443
+ disabled={isSubmitting}
444
+ >
445
+ {#if isSubmitting}
446
+ <span class="vk-spinner"></span>
447
+ Submitting...
448
+ {:else}
449
+ Submit
450
+ {/if}
451
+ </button>
452
+ </form>
453
+ </div>
454
+ {/if}
455
+ </div>
456
+ </div>
457
+ {/if}