@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,444 @@
1
+ <script setup lang="ts">
2
+ /* ── Props / Emits ──────────────────────────────── */
3
+ const props = defineProps<{
4
+ open: boolean;
5
+ templates: Template[];
6
+ }>();
7
+
8
+ const emit = defineEmits<{
9
+ close: [];
10
+ }>();
11
+
12
+ /* ── Reactive state ─────────────────────────────── */
13
+ const step = ref<"identify" | "details" | "success">("identify");
14
+ const selectedTemplateId = ref("");
15
+ const form = ref<FormValues>({ ...initialFormValues, answers: {} });
16
+ const isSubmitting = ref(false);
17
+ const dialogError = ref("");
18
+ const emailLimitReached = ref(false);
19
+
20
+ /* ── Computed ───────────────────────────────────── */
21
+ const selectedTemplate = computed(
22
+ () => props.templates.find((t) => t.id === selectedTemplateId.value) || null,
23
+ );
24
+
25
+ const orderedQuestions = computed(() =>
26
+ [...(selectedTemplate.value?.questions || [])].sort((a, b) => a.order - b.order),
27
+ );
28
+
29
+ const canContinue = computed(() => form.value.email.trim().length > 0);
30
+
31
+ /* ── Methods ────────────────────────────────────── */
32
+ const updateAnswer = (questionId: string, value: unknown) => {
33
+ form.value = {
34
+ ...form.value,
35
+ answers: { ...form.value.answers, [questionId]: value },
36
+ };
37
+ };
38
+
39
+ const toggleCheckboxValue = (questionId: string, value: string, checked: boolean) => {
40
+ const current = Array.isArray(form.value.answers[questionId])
41
+ ? (form.value.answers[questionId] as string[])
42
+ : [];
43
+ const next = checked
44
+ ? [...new Set([...current, value])]
45
+ : current.filter((item) => item !== value);
46
+ updateAnswer(questionId, next);
47
+ };
48
+
49
+ const validateRequired = () => {
50
+ if (!selectedTemplate.value) return "Please select a template.";
51
+ if (!form.value.email.trim()) return "Email is required.";
52
+ if (!form.value.title.trim()) return "Subject is required.";
53
+
54
+ for (const question of orderedQuestions.value) {
55
+ if (!question.required) continue;
56
+ const value = form.value.answers[question.id];
57
+
58
+ if (question.type === "CHECKBOX") {
59
+ if (!Array.isArray(value) || value.length === 0)
60
+ return `"${question.label}" is required.`;
61
+ continue;
62
+ }
63
+ if (question.type === "FILE") {
64
+ if (!(value instanceof File)) return `"${question.label}" is required.`;
65
+ continue;
66
+ }
67
+ if (value === null || value === undefined || String(value).trim() === "") {
68
+ return `"${question.label}" is required.`;
69
+ }
70
+ }
71
+ return "";
72
+ };
73
+
74
+ const resetAndClose = () => {
75
+ step.value = "identify";
76
+ form.value = { ...initialFormValues, answers: {} };
77
+ dialogError.value = "";
78
+ emailLimitReached.value = false;
79
+ selectedTemplateId.value = props.templates.length > 0 ? props.templates[0].id : "";
80
+ emit("close");
81
+ };
82
+
83
+ const handleSubmit = async () => {
84
+ dialogError.value = "";
85
+
86
+ const validationError = validateRequired();
87
+ if (validationError) {
88
+ dialogError.value = validationError;
89
+ return;
90
+ }
91
+ if (!selectedTemplate.value) {
92
+ dialogError.value = "Template is required.";
93
+ return;
94
+ }
95
+
96
+ isSubmitting.value = true;
97
+ try {
98
+ const fileQuestionIds = orderedQuestions.value
99
+ .filter((q) => q.type === "FILE" && form.value.answers[q.id] instanceof File)
100
+ .map((q) => q.id);
101
+
102
+ const result = await createTicket({
103
+ email: form.value.email.trim(),
104
+ title: form.value.title.trim(),
105
+ templateId: selectedTemplate.value.id,
106
+ answers: { ...form.value.answers },
107
+ hasFiles: fileQuestionIds.length > 0,
108
+ fileQuestionIds,
109
+ });
110
+
111
+ emailLimitReached.value = result.emailLimitReached ?? false;
112
+ step.value = "success";
113
+ } catch (submitError) {
114
+ dialogError.value = submitError instanceof Error ? submitError.message : "Unexpected error.";
115
+ } finally {
116
+ isSubmitting.value = false;
117
+ }
118
+ };
119
+
120
+ /* ── Initialize selectedTemplateId when templates are provided ── */
121
+ watch(
122
+ () => props.templates,
123
+ (newTemplates) => {
124
+ if (newTemplates.length > 0 && !selectedTemplateId.value) {
125
+ selectedTemplateId.value = newTemplates[0].id;
126
+ }
127
+ },
128
+ { immediate: true },
129
+ );
130
+ </script>
131
+
132
+ <template>
133
+ <div v-if="open" class="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm" @click="resetAndClose">
134
+ <div
135
+ class="relative mx-4 w-full max-w-lg overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-xl"
136
+ @click.stop
137
+ >
138
+ <!-- Close button -->
139
+ <button
140
+ type="button"
141
+ class="absolute right-4 top-4 z-10 flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg border-none bg-transparent text-slate-500 transition-colors hover:bg-slate-50 hover:text-slate-900"
142
+ aria-label="Close"
143
+ @click="resetAndClose"
144
+ >
145
+ <!-- IconX -->
146
+ <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M18 6 6 18" /><path d="m6 6 12 12" /></svg>
147
+ </button>
148
+
149
+ <!-- Success state -->
150
+ <div v-if="step === 'success'" class="px-6 py-12 text-center">
151
+ <div class="mx-auto mb-5 flex h-14 w-14 items-center justify-center rounded-full bg-green-50 text-green-600">
152
+ <!-- IconCheck -->
153
+ <svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5" /></svg>
154
+ </div>
155
+ <h2 class="m-0 text-xl font-bold text-slate-900">Ticket submitted!</h2>
156
+ <p v-if="emailLimitReached" class="mt-2 text-sm text-slate-500">
157
+ 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.
158
+ </p>
159
+ <p v-else class="mt-2 text-sm text-slate-500">
160
+ Check your email for a secure link to follow your ticket.
161
+ </p>
162
+ <button
163
+ type="button"
164
+ class="mt-6 inline-flex items-center gap-2 !rounded-full border border-slate-200 bg-white !px-7 !py-3 text-sm font-semibold text-slate-900 cursor-pointer transition-all hover:border-slate-300 hover:bg-slate-50"
165
+ @click="resetAndClose"
166
+ >
167
+ Close
168
+ </button>
169
+ </div>
170
+
171
+ <!-- Step 1: Identify -->
172
+ <div v-if="step === 'identify'" class="px-6 py-6">
173
+ <h2 class="m-0 text-lg font-bold text-slate-900">Submit a request</h2>
174
+ <p class="mt-1 text-sm text-slate-500">We'll get back to you as soon as possible.</p>
175
+
176
+ <div class="mt-5 space-y-5">
177
+ <!-- Error alert -->
178
+ <div
179
+ v-if="dialogError"
180
+ class="flex items-start gap-3 rounded-xl border border-red-200 bg-red-50 p-4 text-sm text-red-900"
181
+ role="alert"
182
+ >
183
+ <span class="mt-0.5 shrink-0">
184
+ <!-- IconAlert -->
185
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3Z" /><path d="M12 9v4" /><path d="M12 17h.01" /></svg>
186
+ </span>
187
+ <span class="flex-1">{{ dialogError }}</span>
188
+ <button
189
+ type="button"
190
+ class="shrink-0 cursor-pointer border-none bg-transparent p-0 opacity-50 transition-opacity hover:opacity-100"
191
+ aria-label="Dismiss"
192
+ @click="dialogError = ''"
193
+ >
194
+ <!-- IconX -->
195
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M18 6 6 18" /><path d="m6 6 12 12" /></svg>
196
+ </button>
197
+ </div>
198
+
199
+ <!-- Email -->
200
+ <div class="space-y-1.5">
201
+ <label class="text-sm font-semibold text-slate-900" for="vk-dialog-email">
202
+ Email<span class="text-red-600"> *</span>
203
+ </label>
204
+ <div class="relative">
205
+ <span class="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-slate-500">
206
+ <!-- IconMail -->
207
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><rect width="20" height="16" x="2" y="4" rx="2" /><path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7" /></svg>
208
+ </span>
209
+ <input
210
+ id="vk-dialog-email"
211
+ v-model="form.email"
212
+ class="w-full rounded-lg border border-slate-200 bg-slate-50 py-2.5 pl-10 pr-3 text-sm text-slate-900 transition-all duration-150 placeholder:text-slate-500/60 focus:border-blue-600 focus:outline-none focus:ring-3 focus:ring-blue-600/10"
213
+ type="email"
214
+ placeholder="you@example.com"
215
+ required
216
+ />
217
+ </div>
218
+ <p class="text-xs text-slate-500">We'll contact you at this address.</p>
219
+ </div>
220
+
221
+ <!-- Template selector -->
222
+ <div v-if="templates.length > 1" class="space-y-2">
223
+ <p class="m-0 text-sm font-semibold text-slate-900">What can we help you with?</p>
224
+ <div class="grid gap-2">
225
+ <button
226
+ v-for="template in templates"
227
+ :key="template.id"
228
+ type="button"
229
+ :class="cn(
230
+ 'flex w-full cursor-pointer items-start gap-3 rounded-xl border border-slate-200 bg-slate-50 p-3.5 text-left transition-all duration-150 hover:border-slate-300',
231
+ selectedTemplateId === template.id && '!border-blue-600 !bg-blue-50 ring-3 ring-blue-600/12',
232
+ )"
233
+ @click="selectedTemplateId = template.id"
234
+ >
235
+ <span
236
+ :class="cn(
237
+ 'mt-0.5 flex h-4 w-4 shrink-0 items-center justify-center rounded-full border-2 transition-colors',
238
+ selectedTemplateId === template.id
239
+ ? 'border-blue-600 bg-blue-600'
240
+ : 'border-slate-300 bg-transparent',
241
+ )"
242
+ >
243
+ <span
244
+ v-if="selectedTemplateId === template.id"
245
+ class="block h-1.5 w-1.5 rounded-full bg-white"
246
+ />
247
+ </span>
248
+ <div>
249
+ <span class="block text-sm font-semibold text-slate-900">{{ template.name }}</span>
250
+ <span v-if="template.description" class="mt-0.5 block text-xs text-slate-500">{{ template.description }}</span>
251
+ </div>
252
+ </button>
253
+ </div>
254
+ </div>
255
+
256
+ <!-- Continue -->
257
+ <button
258
+ type="button"
259
+ class="inline-flex w-full items-center justify-center gap-2 rounded-lg border-none bg-blue-600 px-5 py-2.5 text-sm font-semibold text-white cursor-pointer transition-all hover:bg-blue-700 hover:-translate-y-px hover:shadow-lg active:translate-y-0 disabled:opacity-60 disabled:cursor-not-allowed"
260
+ :disabled="!canContinue"
261
+ @click="dialogError = ''; step = 'details'"
262
+ >
263
+ Continue
264
+ </button>
265
+ </div>
266
+ </div>
267
+
268
+ <!-- Step 2: Details -->
269
+ <div v-if="step === 'details'" class="px-6 py-6">
270
+ <button
271
+ type="button"
272
+ class="-ml-2 -mt-1 mb-3 inline-flex cursor-pointer items-center gap-1 rounded-lg border-none bg-transparent px-2 py-1 text-sm font-medium text-slate-500 transition-colors hover:bg-slate-50 hover:text-slate-900"
273
+ @click="dialogError = ''; step = 'identify'"
274
+ >
275
+ <!-- IconArrowLeft -->
276
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="m12 19-7-7 7-7" /><path d="M19 12H5" /></svg>
277
+ Back
278
+ </button>
279
+
280
+ <h2 class="m-0 text-lg font-bold text-slate-900">{{ selectedTemplate?.name || 'Ticket details' }}</h2>
281
+
282
+ <form class="mt-5 space-y-4" @submit.prevent="handleSubmit">
283
+ <!-- Error alert -->
284
+ <div
285
+ v-if="dialogError"
286
+ class="flex items-start gap-3 rounded-xl border border-red-200 bg-red-50 p-4 text-sm text-red-900"
287
+ role="alert"
288
+ >
289
+ <span class="mt-0.5 shrink-0">
290
+ <!-- IconAlert -->
291
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3Z" /><path d="M12 9v4" /><path d="M12 17h.01" /></svg>
292
+ </span>
293
+ <span class="flex-1">{{ dialogError }}</span>
294
+ <button
295
+ type="button"
296
+ class="shrink-0 cursor-pointer border-none bg-transparent p-0 opacity-50 transition-opacity hover:opacity-100"
297
+ aria-label="Dismiss"
298
+ @click="dialogError = ''"
299
+ >
300
+ <!-- IconX -->
301
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M18 6 6 18" /><path d="m6 6 12 12" /></svg>
302
+ </button>
303
+ </div>
304
+
305
+ <!-- Subject -->
306
+ <div class="space-y-1.5">
307
+ <label class="text-sm font-semibold text-slate-900" for="vk-dialog-subject">
308
+ Subject<span class="text-red-600"> *</span>
309
+ </label>
310
+ <input
311
+ id="vk-dialog-subject"
312
+ v-model="form.title"
313
+ class="w-full rounded-lg border border-slate-200 bg-slate-50 px-3 py-2.5 text-sm text-slate-900 transition-all duration-150 placeholder:text-slate-500/60 focus:border-blue-600 focus:outline-none focus:ring-3 focus:ring-blue-600/10"
314
+ type="text"
315
+ placeholder="Brief description of your issue"
316
+ required
317
+ />
318
+ </div>
319
+
320
+ <!-- Dynamic questions -->
321
+ <div v-for="question in orderedQuestions" :key="question.id" class="space-y-1.5">
322
+ <label class="text-sm font-semibold text-slate-900">
323
+ {{ question.label }}<span v-if="question.required" class="text-red-600"> *</span>
324
+ </label>
325
+
326
+ <!-- TEXT -->
327
+ <input
328
+ v-if="question.type === 'TEXT'"
329
+ class="w-full rounded-lg border border-slate-200 bg-slate-50 px-3 py-2.5 text-sm text-slate-900 transition-all duration-150 focus:border-blue-600 focus:outline-none focus:ring-3 focus:ring-blue-600/10"
330
+ type="text"
331
+ :value="String(form.answers[question.id] || '')"
332
+ @input="updateAnswer(question.id, ($event.target as HTMLInputElement).value)"
333
+ />
334
+
335
+ <!-- TEXTAREA -->
336
+ <textarea
337
+ v-else-if="question.type === 'TEXTAREA'"
338
+ class="min-h-[100px] w-full resize-y rounded-lg border border-slate-200 bg-slate-50 px-3 py-2.5 text-sm text-slate-900 transition-all duration-150 focus:border-blue-600 focus:outline-none focus:ring-3 focus:ring-blue-600/10"
339
+ :value="String(form.answers[question.id] || '')"
340
+ @input="updateAnswer(question.id, ($event.target as HTMLTextAreaElement).value)"
341
+ />
342
+
343
+ <!-- DATE -->
344
+ <input
345
+ v-else-if="question.type === 'DATE'"
346
+ class="w-full rounded-lg border border-slate-200 bg-slate-50 px-3 py-2.5 text-sm text-slate-900 transition-all duration-150 focus:border-blue-600 focus:outline-none focus:ring-3 focus:ring-blue-600/10"
347
+ type="date"
348
+ :value="String(form.answers[question.id] || '')"
349
+ @input="updateAnswer(question.id, ($event.target as HTMLInputElement).value)"
350
+ />
351
+
352
+ <!-- SELECT -->
353
+ <select
354
+ v-else-if="question.type === 'SELECT'"
355
+ class="w-full rounded-lg border border-slate-200 bg-slate-50 px-3 py-2.5 text-sm text-slate-900 transition-all duration-150 focus:border-blue-600 focus:outline-none focus:ring-3 focus:ring-blue-600/10"
356
+ :value="String(form.answers[question.id] || '')"
357
+ @change="updateAnswer(question.id, ($event.target as HTMLSelectElement).value)"
358
+ >
359
+ <option value="">Select an option</option>
360
+ <option
361
+ v-for="option in question.options || []"
362
+ :key="option.id"
363
+ :value="option.value"
364
+ >
365
+ {{ option.label }}
366
+ </option>
367
+ </select>
368
+
369
+ <!-- CHECKBOX -->
370
+ <div v-else-if="question.type === 'CHECKBOX'" class="space-y-2 pt-1">
371
+ <label
372
+ v-for="option in question.options || []"
373
+ :key="option.id"
374
+ class="flex items-center gap-2.5 text-sm text-slate-900"
375
+ >
376
+ <input
377
+ type="checkbox"
378
+ class="h-4 w-4 rounded accent-blue-600"
379
+ :checked="
380
+ Array.isArray(form.answers[question.id]) &&
381
+ (form.answers[question.id] as string[]).includes(option.value)
382
+ "
383
+ @change="toggleCheckboxValue(question.id, option.value, ($event.target as HTMLInputElement).checked)"
384
+ />
385
+ <span>{{ option.label }}</span>
386
+ </label>
387
+ </div>
388
+
389
+ <!-- FILE -->
390
+ <template v-else-if="question.type === 'FILE'">
391
+ <div v-if="form.answers[question.id] instanceof File" class="flex flex-wrap gap-1.5">
392
+ <span class="inline-flex items-center gap-1.5 rounded-full border border-slate-200 bg-slate-50 px-3 py-1.5 text-xs text-slate-500">
393
+ {{ (form.answers[question.id] as File).name }}
394
+ <button
395
+ type="button"
396
+ class="flex cursor-pointer items-center border-none bg-transparent p-0 text-slate-500 transition-colors hover:text-red-600"
397
+ aria-label="Remove file"
398
+ @click="updateAnswer(question.id, null)"
399
+ >
400
+ <!-- IconX -->
401
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M18 6 6 18" /><path d="m6 6 12 12" /></svg>
402
+ </button>
403
+ </span>
404
+ </div>
405
+ <input
406
+ v-else
407
+ class="w-full rounded-lg border border-slate-200 bg-slate-50 px-3 py-2.5 text-sm text-slate-900 transition-all duration-150 focus:border-blue-600 focus:outline-none focus:ring-3 focus:ring-blue-600/10"
408
+ type="file"
409
+ accept="image/jpeg,image/png,image/gif,image/webp"
410
+ @change="(e: Event) => {
411
+ const input = e.target as HTMLInputElement;
412
+ const file = input.files?.[0] || null;
413
+ if (file && !['image/png','image/jpeg','image/gif','image/webp'].includes(file.type)) {
414
+ input.value = '';
415
+ return;
416
+ }
417
+ updateAnswer(question.id, file);
418
+ }"
419
+ />
420
+ </template>
421
+ </div>
422
+
423
+ <!-- Actions -->
424
+ <div class="flex items-center gap-3 pt-2">
425
+ <button
426
+ class="inline-flex flex-1 items-center justify-center gap-2 rounded-lg border-none bg-blue-600 px-5 py-2.5 text-sm font-semibold text-white cursor-pointer transition-all hover:bg-blue-700 hover:-translate-y-px hover:shadow-lg active:translate-y-0 disabled:opacity-60 disabled:cursor-not-allowed"
427
+ type="submit"
428
+ :disabled="isSubmitting"
429
+ >
430
+ <template v-if="isSubmitting">
431
+ <!-- IconSpinner -->
432
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" class="animate-spin"><path d="M21 12a9 9 0 1 1-6.219-8.56" /></svg>
433
+ Submitting...
434
+ </template>
435
+ <template v-else>
436
+ Submit
437
+ </template>
438
+ </button>
439
+ </div>
440
+ </form>
441
+ </div>
442
+ </div>
443
+ </div>
444
+ </template>