@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.
- package/README.md +52 -0
- package/bin/create-vicket-support.js +389 -0
- package/package.json +18 -0
- package/templates/next/src/app/api/vicket/[...path]/route.ts +59 -0
- package/templates/next/src/app/components/vicket/TicketDialog.tsx +514 -0
- package/templates/next/src/app/support/page.tsx +358 -0
- package/templates/next/src/app/ticket/page.tsx +483 -0
- package/templates/next/src/app/utils/vicket/api.ts +149 -0
- package/templates/next/src/app/utils/vicket/types.ts +85 -0
- package/templates/next/src/app/utils/vicket/utils.ts +49 -0
- package/templates/next/src/app/vicket.css +1325 -0
- package/templates/nuxt/app/assets/css/vicket.css +1325 -0
- package/templates/nuxt/app/components/VicketTicketDialog.vue +499 -0
- package/templates/nuxt/app/composables/useVicket.ts +274 -0
- package/templates/nuxt/app/pages/support.vue +303 -0
- package/templates/nuxt/app/pages/ticket.vue +434 -0
- package/templates/nuxt/server/api/vicket/[...path].ts +85 -0
- package/templates/sveltekit/src/lib/vicket/TicketDialog.svelte +459 -0
- package/templates/sveltekit/src/lib/vicket/api.ts +162 -0
- package/templates/sveltekit/src/lib/vicket/types.ts +87 -0
- package/templates/sveltekit/src/lib/vicket/utils.ts +55 -0
- package/templates/sveltekit/src/lib/vicket.css +1325 -0
- package/templates/sveltekit/src/routes/api/vicket/[...path]/+server.ts +77 -0
- package/templates/sveltekit/src/routes/support/+page.svelte +316 -0
- package/templates/sveltekit/src/routes/ticket/+page.svelte +418 -0
- package/templates-tailwind/next/src/app/api/vicket/init/route.ts +24 -0
- package/templates-tailwind/next/src/app/api/vicket/messages/route.ts +36 -0
- package/templates-tailwind/next/src/app/api/vicket/thread/route.ts +27 -0
- package/templates-tailwind/next/src/app/api/vicket/tickets/route.ts +37 -0
- package/templates-tailwind/next/src/app/support/page.tsx +5 -0
- package/templates-tailwind/next/src/app/ticket/page.tsx +10 -0
- package/templates-tailwind/next/src/components/vicket/support-page.tsx +359 -0
- package/templates-tailwind/next/src/components/vicket/ticket-dialog.tsx +306 -0
- package/templates-tailwind/next/src/components/vicket/ticket-page.tsx +425 -0
- package/templates-tailwind/next/src/lib/vicket.ts +257 -0
- package/templates-tailwind/nuxt/app/components/VicketSupportPage.vue +317 -0
- package/templates-tailwind/nuxt/app/components/VicketTicketDialog.vue +444 -0
- package/templates-tailwind/nuxt/app/components/VicketTicketPage.vue +449 -0
- package/templates-tailwind/nuxt/app/composables/use-vicket.ts +249 -0
- package/templates-tailwind/nuxt/app/pages/support.vue +3 -0
- package/templates-tailwind/nuxt/app/pages/ticket.vue +3 -0
- package/templates-tailwind/nuxt/server/api/vicket/init.get.ts +22 -0
- package/templates-tailwind/nuxt/server/api/vicket/messages.post.ts +56 -0
- package/templates-tailwind/nuxt/server/api/vicket/thread.get.ts +26 -0
- package/templates-tailwind/nuxt/server/api/vicket/tickets.post.ts +53 -0
- package/templates-tailwind/sveltekit/src/lib/vicket/SupportPage.svelte +395 -0
- package/templates-tailwind/sveltekit/src/lib/vicket/TicketDialog.svelte +406 -0
- package/templates-tailwind/sveltekit/src/lib/vicket/TicketPage.svelte +465 -0
- package/templates-tailwind/sveltekit/src/lib/vicket/index.ts +257 -0
- package/templates-tailwind/sveltekit/src/routes/api/vicket/init/+server.ts +22 -0
- package/templates-tailwind/sveltekit/src/routes/api/vicket/messages/+server.ts +40 -0
- package/templates-tailwind/sveltekit/src/routes/api/vicket/thread/+server.ts +25 -0
- package/templates-tailwind/sveltekit/src/routes/api/vicket/tickets/+server.ts +37 -0
- package/templates-tailwind/sveltekit/src/routes/support/+page.svelte +5 -0
- 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
|
+
✕
|
|
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
|
+
✕
|
|
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
|
+
📷 {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
|
+
✕
|
|
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>✓</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'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">✉</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'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
|
+
← 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
|
+
}
|