@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,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
|
+
✕
|
|
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>✓</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>⚠</span>
|
|
252
|
+
<span>{{ dialogError }}</span>
|
|
253
|
+
<button
|
|
254
|
+
type="button"
|
|
255
|
+
class="vk-alert-dismiss"
|
|
256
|
+
aria-label="Dismiss"
|
|
257
|
+
@click="dialogError = ''"
|
|
258
|
+
>
|
|
259
|
+
✕
|
|
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">✉</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
|
+
← 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>⚠</span>
|
|
338
|
+
<span>{{ dialogError }}</span>
|
|
339
|
+
<button
|
|
340
|
+
type="button"
|
|
341
|
+
class="vk-alert-dismiss"
|
|
342
|
+
aria-label="Dismiss"
|
|
343
|
+
@click="dialogError = ''"
|
|
344
|
+
>
|
|
345
|
+
✕
|
|
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
|
+
✕
|
|
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
|
+
📷
|
|
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>
|