@vicket/create-support 1.1.1

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