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