@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,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>
|