@vicket/create-support 0.1.0
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 +53 -0
- package/bin/create-vicket-support.js +374 -0
- package/package.json +13 -0
- package/templates/next/src/app/support/page.tsx +398 -0
- package/templates/next/src/app/ticket/page.tsx +250 -0
- package/templates/next/src/app/vicket.css +273 -0
- package/templates/nuxt/assets/css/vicket.css +273 -0
- package/templates/nuxt/pages/support.vue +351 -0
- package/templates/nuxt/pages/ticket.vue +238 -0
- package/templates/react/src/vicket/SupportPage.tsx +399 -0
- package/templates/react/src/vicket/TicketPage.tsx +247 -0
- package/templates/react/src/vicket/vicket.css +273 -0
- package/templates/sveltekit/src/lib/vicket.css +273 -0
- package/templates/sveltekit/src/routes/support/+page.svelte +338 -0
- package/templates/sveltekit/src/routes/ticket/+page.svelte +242 -0
- package/templates/vue/src/vicket/SupportPage.vue +348 -0
- package/templates/vue/src/vicket/TicketPage.vue +235 -0
- package/templates/vue/src/vicket/vicket.css +273 -0
- package/templates-tailwind/next/src/app/vicket.css +162 -0
- package/templates-tailwind/nuxt/assets/css/vicket.css +162 -0
- package/templates-tailwind/react/src/vicket/vicket.css +162 -0
- package/templates-tailwind/sveltekit/src/lib/vicket.css +162 -0
- package/templates-tailwind/vue/src/vicket/vicket.css +162 -0
|
@@ -0,0 +1,348 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
const API_URL = (import.meta.env.VITE_VICKET_API_URL || "").replace(/\/$/, "");
|
|
3
|
+
const API_KEY = import.meta.env.VITE_VICKET_API_KEY || "";
|
|
4
|
+
|
|
5
|
+
type TemplateOption = {
|
|
6
|
+
id: string;
|
|
7
|
+
label: string;
|
|
8
|
+
value: string;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
type TemplateQuestion = {
|
|
12
|
+
id: string;
|
|
13
|
+
label: string;
|
|
14
|
+
type: "TEXT" | "TEXTAREA" | "SELECT" | "CHECKBOX" | "DATE" | "FILE";
|
|
15
|
+
required: boolean;
|
|
16
|
+
order: number;
|
|
17
|
+
options?: TemplateOption[];
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
type Template = {
|
|
21
|
+
id: string;
|
|
22
|
+
name: string;
|
|
23
|
+
description: string;
|
|
24
|
+
questions: TemplateQuestion[];
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
type InitPayload = {
|
|
28
|
+
success?: boolean;
|
|
29
|
+
error?: string;
|
|
30
|
+
data?: {
|
|
31
|
+
website?: { name?: string };
|
|
32
|
+
templates: Template[];
|
|
33
|
+
};
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
const templates = ref<Template[]>([]);
|
|
37
|
+
const websiteName = ref("Support");
|
|
38
|
+
const selectedTemplateId = ref("");
|
|
39
|
+
const form = reactive<{
|
|
40
|
+
email: string;
|
|
41
|
+
title: string;
|
|
42
|
+
answers: Record<string, unknown>;
|
|
43
|
+
}>({
|
|
44
|
+
email: "",
|
|
45
|
+
title: "",
|
|
46
|
+
answers: {},
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
const isLoading = ref(true);
|
|
50
|
+
const isSubmitting = ref(false);
|
|
51
|
+
const error = ref("");
|
|
52
|
+
const success = ref("");
|
|
53
|
+
|
|
54
|
+
const selectedTemplate = computed(
|
|
55
|
+
() => templates.value.find((template) => template.id === selectedTemplateId.value) || null,
|
|
56
|
+
);
|
|
57
|
+
const orderedQuestions = computed(() =>
|
|
58
|
+
[...(selectedTemplate.value?.questions || [])].sort((a, b) => a.order - b.order),
|
|
59
|
+
);
|
|
60
|
+
|
|
61
|
+
const loadSupport = async () => {
|
|
62
|
+
isLoading.value = true;
|
|
63
|
+
error.value = "";
|
|
64
|
+
try {
|
|
65
|
+
if (!API_URL || !API_KEY) {
|
|
66
|
+
throw new Error("Missing VITE_VICKET_API_URL or VITE_VICKET_API_KEY.");
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const response = await fetch(`${API_URL}/public/support/init`, {
|
|
70
|
+
method: "GET",
|
|
71
|
+
headers: {
|
|
72
|
+
"X-Api-Key": API_KEY,
|
|
73
|
+
"Content-Type": "application/json",
|
|
74
|
+
},
|
|
75
|
+
});
|
|
76
|
+
const payload = (await response.json()) as InitPayload;
|
|
77
|
+
if (!response.ok || !payload?.success || !payload?.data) {
|
|
78
|
+
throw new Error(payload?.error || "Failed to load support data.");
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
templates.value = payload.data.templates || [];
|
|
82
|
+
websiteName.value = payload.data.website?.name || "Support";
|
|
83
|
+
if (templates.value.length > 0) {
|
|
84
|
+
selectedTemplateId.value = templates.value[0].id;
|
|
85
|
+
}
|
|
86
|
+
} catch (loadError) {
|
|
87
|
+
error.value = loadError instanceof Error ? loadError.message : "Unexpected error.";
|
|
88
|
+
} finally {
|
|
89
|
+
isLoading.value = false;
|
|
90
|
+
}
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
const updateAnswer = (questionId: string, value: unknown) => {
|
|
94
|
+
form.answers = {
|
|
95
|
+
...form.answers,
|
|
96
|
+
[questionId]: value,
|
|
97
|
+
};
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
const toggleCheckboxValue = (questionId: string, value: string, checked: boolean) => {
|
|
101
|
+
const current = Array.isArray(form.answers[questionId]) ? (form.answers[questionId] as string[]) : [];
|
|
102
|
+
const next = checked ? [...new Set([...current, value])] : current.filter((item) => item !== value);
|
|
103
|
+
updateAnswer(questionId, next);
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
const validateRequired = () => {
|
|
107
|
+
if (!selectedTemplate.value) return "Please select a template.";
|
|
108
|
+
if (!form.email.trim()) return "Email is required.";
|
|
109
|
+
if (!form.title.trim()) return "Subject is required.";
|
|
110
|
+
|
|
111
|
+
for (const question of orderedQuestions.value) {
|
|
112
|
+
if (!question.required) continue;
|
|
113
|
+
const value = form.answers[question.id];
|
|
114
|
+
|
|
115
|
+
if (question.type === "CHECKBOX") {
|
|
116
|
+
if (!Array.isArray(value) || value.length === 0) {
|
|
117
|
+
return `Question "${question.label}" is required.`;
|
|
118
|
+
}
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
if (question.type === "FILE") {
|
|
123
|
+
if (!(value instanceof File)) {
|
|
124
|
+
return `Question "${question.label}" is required.`;
|
|
125
|
+
}
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
if (value === null || value === undefined || String(value).trim() === "") {
|
|
130
|
+
return `Question "${question.label}" is required.`;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
return "";
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
const submitTicket = async () => {
|
|
138
|
+
error.value = "";
|
|
139
|
+
success.value = "";
|
|
140
|
+
|
|
141
|
+
const validationError = validateRequired();
|
|
142
|
+
if (validationError) {
|
|
143
|
+
error.value = validationError;
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
if (!selectedTemplate.value) {
|
|
148
|
+
error.value = "Template is required.";
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
isSubmitting.value = true;
|
|
153
|
+
try {
|
|
154
|
+
const payload = {
|
|
155
|
+
email: form.email.trim(),
|
|
156
|
+
title: form.title.trim(),
|
|
157
|
+
templateId: selectedTemplate.value.id,
|
|
158
|
+
answers: { ...form.answers },
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
const hasFiles = orderedQuestions.value.some(
|
|
162
|
+
(question) => question.type === "FILE" && payload.answers[question.id] instanceof File,
|
|
163
|
+
);
|
|
164
|
+
|
|
165
|
+
if (hasFiles) {
|
|
166
|
+
const formData = new FormData();
|
|
167
|
+
const normalizedAnswers: Record<string, unknown> = {};
|
|
168
|
+
|
|
169
|
+
for (const [questionId, answer] of Object.entries(payload.answers)) {
|
|
170
|
+
if (answer instanceof File) {
|
|
171
|
+
formData.append(`files[${questionId}]`, answer);
|
|
172
|
+
normalizedAnswers[questionId] = "__isFile:true";
|
|
173
|
+
} else {
|
|
174
|
+
normalizedAnswers[questionId] = answer;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
formData.append(
|
|
179
|
+
"data",
|
|
180
|
+
JSON.stringify({
|
|
181
|
+
...payload,
|
|
182
|
+
answers: normalizedAnswers,
|
|
183
|
+
}),
|
|
184
|
+
);
|
|
185
|
+
|
|
186
|
+
const response = await fetch(`${API_URL}/public/support/tickets`, {
|
|
187
|
+
method: "POST",
|
|
188
|
+
headers: {
|
|
189
|
+
"X-Api-Key": API_KEY,
|
|
190
|
+
},
|
|
191
|
+
body: formData,
|
|
192
|
+
});
|
|
193
|
+
const responsePayload = (await response.json()) as { success?: boolean; error?: string };
|
|
194
|
+
if (!response.ok || !responsePayload?.success) {
|
|
195
|
+
throw new Error(responsePayload?.error || "Failed to create ticket.");
|
|
196
|
+
}
|
|
197
|
+
} else {
|
|
198
|
+
const response = await fetch(`${API_URL}/public/support/tickets`, {
|
|
199
|
+
method: "POST",
|
|
200
|
+
headers: {
|
|
201
|
+
"X-Api-Key": API_KEY,
|
|
202
|
+
"Content-Type": "application/json",
|
|
203
|
+
},
|
|
204
|
+
body: JSON.stringify(payload),
|
|
205
|
+
});
|
|
206
|
+
const responsePayload = (await response.json()) as { success?: boolean; error?: string };
|
|
207
|
+
if (!response.ok || !responsePayload?.success) {
|
|
208
|
+
throw new Error(responsePayload?.error || "Failed to create ticket.");
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
success.value = "Ticket created. Ask the user to open the secure email link to continue.";
|
|
213
|
+
form.email = "";
|
|
214
|
+
form.title = "";
|
|
215
|
+
form.answers = {};
|
|
216
|
+
} catch (submitError) {
|
|
217
|
+
error.value = submitError instanceof Error ? submitError.message : "Unexpected error.";
|
|
218
|
+
} finally {
|
|
219
|
+
isSubmitting.value = false;
|
|
220
|
+
}
|
|
221
|
+
};
|
|
222
|
+
|
|
223
|
+
onMounted(() => {
|
|
224
|
+
void loadSupport();
|
|
225
|
+
});
|
|
226
|
+
</script>
|
|
227
|
+
|
|
228
|
+
<template>
|
|
229
|
+
<div class="vk-shell">
|
|
230
|
+
<div class="vk-card">
|
|
231
|
+
<header class="vk-header">
|
|
232
|
+
<h1 class="vk-title">{{ websiteName }}</h1>
|
|
233
|
+
<p class="vk-subtitle">Create a new support ticket directly from your own page.</p>
|
|
234
|
+
</header>
|
|
235
|
+
|
|
236
|
+
<div class="vk-body">
|
|
237
|
+
<div v-if="isLoading" class="vk-alert">Loading support data...</div>
|
|
238
|
+
<form v-else class="vk-stack" @submit.prevent="submitTicket">
|
|
239
|
+
<div v-if="error" class="vk-alert error">{{ error }}</div>
|
|
240
|
+
<div v-if="success" class="vk-alert success">{{ success }}</div>
|
|
241
|
+
|
|
242
|
+
<div class="vk-stack">
|
|
243
|
+
<p class="vk-section-label">Ticket template</p>
|
|
244
|
+
<div class="vk-grid two">
|
|
245
|
+
<button
|
|
246
|
+
v-for="template in templates"
|
|
247
|
+
:key="template.id"
|
|
248
|
+
type="button"
|
|
249
|
+
class="vk-template-button"
|
|
250
|
+
:class="{ active: selectedTemplateId === template.id }"
|
|
251
|
+
@click="selectedTemplateId = template.id"
|
|
252
|
+
>
|
|
253
|
+
<span class="vk-template-name">{{ template.name }}</span>
|
|
254
|
+
<span class="vk-template-description">{{ template.description || "No description" }}</span>
|
|
255
|
+
</button>
|
|
256
|
+
</div>
|
|
257
|
+
</div>
|
|
258
|
+
|
|
259
|
+
<div class="vk-grid two">
|
|
260
|
+
<div class="vk-field">
|
|
261
|
+
<label class="vk-label required" for="email">Email</label>
|
|
262
|
+
<input id="email" v-model="form.email" class="vk-input" type="email" required />
|
|
263
|
+
</div>
|
|
264
|
+
|
|
265
|
+
<div class="vk-field">
|
|
266
|
+
<label class="vk-label required" for="title">Subject</label>
|
|
267
|
+
<input id="title" v-model="form.title" class="vk-input" type="text" required />
|
|
268
|
+
</div>
|
|
269
|
+
</div>
|
|
270
|
+
|
|
271
|
+
<div v-for="question in orderedQuestions" :key="question.id" class="vk-field">
|
|
272
|
+
<label class="vk-label" :class="{ required: question.required }">{{ question.label }}</label>
|
|
273
|
+
|
|
274
|
+
<input
|
|
275
|
+
v-if="question.type === 'TEXT'"
|
|
276
|
+
class="vk-input"
|
|
277
|
+
type="text"
|
|
278
|
+
:value="String(form.answers[question.id] || '')"
|
|
279
|
+
@input="updateAnswer(question.id, ($event.target as HTMLInputElement).value)"
|
|
280
|
+
/>
|
|
281
|
+
|
|
282
|
+
<textarea
|
|
283
|
+
v-else-if="question.type === 'TEXTAREA'"
|
|
284
|
+
class="vk-textarea"
|
|
285
|
+
:value="String(form.answers[question.id] || '')"
|
|
286
|
+
@input="updateAnswer(question.id, ($event.target as HTMLTextAreaElement).value)"
|
|
287
|
+
/>
|
|
288
|
+
|
|
289
|
+
<input
|
|
290
|
+
v-else-if="question.type === 'DATE'"
|
|
291
|
+
class="vk-input"
|
|
292
|
+
type="date"
|
|
293
|
+
:value="String(form.answers[question.id] || '')"
|
|
294
|
+
@input="updateAnswer(question.id, ($event.target as HTMLInputElement).value)"
|
|
295
|
+
/>
|
|
296
|
+
|
|
297
|
+
<select
|
|
298
|
+
v-else-if="question.type === 'SELECT'"
|
|
299
|
+
class="vk-select"
|
|
300
|
+
:value="String(form.answers[question.id] || '')"
|
|
301
|
+
@change="updateAnswer(question.id, ($event.target as HTMLSelectElement).value)"
|
|
302
|
+
>
|
|
303
|
+
<option value="">Select an option</option>
|
|
304
|
+
<option v-for="option in question.options || []" :key="option.id" :value="option.value">
|
|
305
|
+
{{ option.label }}
|
|
306
|
+
</option>
|
|
307
|
+
</select>
|
|
308
|
+
|
|
309
|
+
<div v-else-if="question.type === 'CHECKBOX'" class="vk-checkbox-list">
|
|
310
|
+
<label v-for="option in question.options || []" :key="option.id" class="vk-checkbox-item">
|
|
311
|
+
<input
|
|
312
|
+
type="checkbox"
|
|
313
|
+
:checked="
|
|
314
|
+
Array.isArray(form.answers[question.id]) &&
|
|
315
|
+
(form.answers[question.id] as string[]).includes(option.value)
|
|
316
|
+
"
|
|
317
|
+
@change="
|
|
318
|
+
toggleCheckboxValue(
|
|
319
|
+
question.id,
|
|
320
|
+
option.value,
|
|
321
|
+
($event.target as HTMLInputElement).checked,
|
|
322
|
+
)
|
|
323
|
+
"
|
|
324
|
+
/>
|
|
325
|
+
<span>{{ option.label }}</span>
|
|
326
|
+
</label>
|
|
327
|
+
</div>
|
|
328
|
+
|
|
329
|
+
<input
|
|
330
|
+
v-else-if="question.type === 'FILE'"
|
|
331
|
+
class="vk-input"
|
|
332
|
+
type="file"
|
|
333
|
+
@change="updateAnswer(question.id, ($event.target as HTMLInputElement).files?.[0] || null)"
|
|
334
|
+
/>
|
|
335
|
+
</div>
|
|
336
|
+
|
|
337
|
+
<div class="vk-actions">
|
|
338
|
+
<button class="vk-button primary" type="submit" :disabled="isSubmitting">
|
|
339
|
+
{{ isSubmitting ? "Submitting..." : "Submit ticket" }}
|
|
340
|
+
</button>
|
|
341
|
+
</div>
|
|
342
|
+
</form>
|
|
343
|
+
</div>
|
|
344
|
+
</div>
|
|
345
|
+
</div>
|
|
346
|
+
</template>
|
|
347
|
+
|
|
348
|
+
<style src="./vicket.css"></style>
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
const API_URL = (import.meta.env.VITE_VICKET_API_URL || "").replace(/\/$/, "");
|
|
3
|
+
const API_KEY = import.meta.env.VITE_VICKET_API_KEY || "";
|
|
4
|
+
|
|
5
|
+
type Attachment = {
|
|
6
|
+
id: string;
|
|
7
|
+
original_filename: string;
|
|
8
|
+
url: string;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
type Message = {
|
|
12
|
+
id: string;
|
|
13
|
+
content: string;
|
|
14
|
+
author_type: "reporter" | "user" | "system";
|
|
15
|
+
created_at: string;
|
|
16
|
+
attachments?: Attachment[];
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
type TicketThread = {
|
|
20
|
+
id: string;
|
|
21
|
+
title: string;
|
|
22
|
+
status?: { label: string };
|
|
23
|
+
priority?: { label: string };
|
|
24
|
+
messages: Message[];
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const token = ref("");
|
|
28
|
+
const thread = ref<TicketThread | null>(null);
|
|
29
|
+
const content = ref("");
|
|
30
|
+
const files = ref<File[]>([]);
|
|
31
|
+
const isLoading = ref(true);
|
|
32
|
+
const isSending = ref(false);
|
|
33
|
+
const error = ref("");
|
|
34
|
+
const success = ref("");
|
|
35
|
+
|
|
36
|
+
const loadThread = async () => {
|
|
37
|
+
if (!token.value.trim()) {
|
|
38
|
+
isLoading.value = false;
|
|
39
|
+
error.value = "Missing ticket token in URL.";
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
isLoading.value = true;
|
|
44
|
+
error.value = "";
|
|
45
|
+
try {
|
|
46
|
+
if (!API_URL || !API_KEY) {
|
|
47
|
+
throw new Error("Missing VITE_VICKET_API_URL or VITE_VICKET_API_KEY.");
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const response = await fetch(`${API_URL}/public/support/ticket?token=${encodeURIComponent(token.value)}`, {
|
|
51
|
+
method: "GET",
|
|
52
|
+
headers: {
|
|
53
|
+
"X-Api-Key": API_KEY,
|
|
54
|
+
"Content-Type": "application/json",
|
|
55
|
+
},
|
|
56
|
+
});
|
|
57
|
+
const payload = (await response.json()) as {
|
|
58
|
+
success?: boolean;
|
|
59
|
+
error?: string;
|
|
60
|
+
error_code?: string;
|
|
61
|
+
data?: TicketThread;
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
if (!response.ok || !payload?.success || !payload?.data) {
|
|
65
|
+
if (payload?.error_code === "ticket-link-expired") {
|
|
66
|
+
throw new Error("Ce lien a expire. Un nouveau lien securise vient d'etre envoye par email.");
|
|
67
|
+
}
|
|
68
|
+
throw new Error(payload?.error || "Failed to load ticket.");
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
thread.value = payload.data;
|
|
72
|
+
} catch (loadError) {
|
|
73
|
+
error.value = loadError instanceof Error ? loadError.message : "Unexpected error.";
|
|
74
|
+
} finally {
|
|
75
|
+
isLoading.value = false;
|
|
76
|
+
}
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
const submitReply = async () => {
|
|
80
|
+
error.value = "";
|
|
81
|
+
success.value = "";
|
|
82
|
+
|
|
83
|
+
if (!content.value.trim() && files.value.length === 0) {
|
|
84
|
+
error.value = "Reply content is required.";
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (!token.value.trim()) {
|
|
89
|
+
error.value = "Missing ticket token.";
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
isSending.value = true;
|
|
94
|
+
try {
|
|
95
|
+
if (files.value.length > 0) {
|
|
96
|
+
const formData = new FormData();
|
|
97
|
+
formData.append("data", JSON.stringify({ content: content.value.trim() }));
|
|
98
|
+
for (const file of files.value) {
|
|
99
|
+
formData.append("files", file);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const response = await fetch(
|
|
103
|
+
`${API_URL}/public/support/ticket/messages?token=${encodeURIComponent(token.value)}`,
|
|
104
|
+
{
|
|
105
|
+
method: "POST",
|
|
106
|
+
headers: {
|
|
107
|
+
"X-Api-Key": API_KEY,
|
|
108
|
+
},
|
|
109
|
+
body: formData,
|
|
110
|
+
},
|
|
111
|
+
);
|
|
112
|
+
const payload = (await response.json()) as { success?: boolean; error?: string; error_code?: string };
|
|
113
|
+
if (!response.ok || !payload?.success) {
|
|
114
|
+
if (payload?.error_code === "ticket-link-expired") {
|
|
115
|
+
throw new Error("Ce lien a expire. Un nouveau lien securise vient d'etre envoye par email.");
|
|
116
|
+
}
|
|
117
|
+
throw new Error(payload?.error || "Failed to send reply.");
|
|
118
|
+
}
|
|
119
|
+
} else {
|
|
120
|
+
const response = await fetch(
|
|
121
|
+
`${API_URL}/public/support/ticket/messages?token=${encodeURIComponent(token.value)}`,
|
|
122
|
+
{
|
|
123
|
+
method: "POST",
|
|
124
|
+
headers: {
|
|
125
|
+
"X-Api-Key": API_KEY,
|
|
126
|
+
"Content-Type": "application/json",
|
|
127
|
+
},
|
|
128
|
+
body: JSON.stringify({
|
|
129
|
+
content: content.value.trim(),
|
|
130
|
+
}),
|
|
131
|
+
},
|
|
132
|
+
);
|
|
133
|
+
const payload = (await response.json()) as { success?: boolean; error?: string; error_code?: string };
|
|
134
|
+
if (!response.ok || !payload?.success) {
|
|
135
|
+
if (payload?.error_code === "ticket-link-expired") {
|
|
136
|
+
throw new Error("Ce lien a expire. Un nouveau lien securise vient d'etre envoye par email.");
|
|
137
|
+
}
|
|
138
|
+
throw new Error(payload?.error || "Failed to send reply.");
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
content.value = "";
|
|
143
|
+
files.value = [];
|
|
144
|
+
success.value = "Reply sent.";
|
|
145
|
+
await loadThread();
|
|
146
|
+
} catch (sendError) {
|
|
147
|
+
error.value = sendError instanceof Error ? sendError.message : "Unexpected error.";
|
|
148
|
+
} finally {
|
|
149
|
+
isSending.value = false;
|
|
150
|
+
}
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
onMounted(() => {
|
|
154
|
+
token.value = new URLSearchParams(window.location.search).get("token") || "";
|
|
155
|
+
void loadThread();
|
|
156
|
+
});
|
|
157
|
+
</script>
|
|
158
|
+
|
|
159
|
+
<template>
|
|
160
|
+
<div class="vk-shell">
|
|
161
|
+
<div class="vk-card">
|
|
162
|
+
<header class="vk-header">
|
|
163
|
+
<h1 class="vk-title">{{ thread?.title || "Ticket" }}</h1>
|
|
164
|
+
<div class="vk-thread-header">
|
|
165
|
+
<span v-if="thread?.status?.label" class="vk-badge">Status: {{ thread.status.label }}</span>
|
|
166
|
+
<span v-if="thread?.priority?.label" class="vk-badge">Priority: {{ thread.priority.label }}</span>
|
|
167
|
+
</div>
|
|
168
|
+
<p class="vk-subtitle">Follow and reply to your ticket here.</p>
|
|
169
|
+
</header>
|
|
170
|
+
|
|
171
|
+
<div class="vk-body vk-stack">
|
|
172
|
+
<div v-if="isLoading" class="vk-alert">Loading ticket...</div>
|
|
173
|
+
<div v-if="error" class="vk-alert error">{{ error }}</div>
|
|
174
|
+
<div v-if="success" class="vk-alert success">{{ success }}</div>
|
|
175
|
+
|
|
176
|
+
<template v-if="!isLoading && thread">
|
|
177
|
+
<section class="vk-message-list">
|
|
178
|
+
<div v-if="thread.messages.length === 0" class="vk-alert">No messages yet.</div>
|
|
179
|
+
<article v-for="message in thread.messages" :key="message.id" class="vk-message">
|
|
180
|
+
<div class="vk-message-meta">
|
|
181
|
+
<strong>{{ message.author_type }}</strong>
|
|
182
|
+
<span>{{ new Date(message.created_at).toLocaleString() }}</span>
|
|
183
|
+
</div>
|
|
184
|
+
<div class="vk-message-body">{{ message.content }}</div>
|
|
185
|
+
|
|
186
|
+
<div v-if="message.attachments?.length" class="vk-attachments">
|
|
187
|
+
<a
|
|
188
|
+
v-for="attachment in message.attachments"
|
|
189
|
+
:key="attachment.id"
|
|
190
|
+
class="vk-attachment"
|
|
191
|
+
:href="attachment.url"
|
|
192
|
+
target="_blank"
|
|
193
|
+
rel="noopener noreferrer"
|
|
194
|
+
>
|
|
195
|
+
{{ attachment.original_filename }}
|
|
196
|
+
</a>
|
|
197
|
+
</div>
|
|
198
|
+
</article>
|
|
199
|
+
</section>
|
|
200
|
+
|
|
201
|
+
<form class="vk-stack" @submit.prevent="submitReply">
|
|
202
|
+
<div class="vk-field">
|
|
203
|
+
<label class="vk-label required" for="reply-content">Reply</label>
|
|
204
|
+
<textarea
|
|
205
|
+
id="reply-content"
|
|
206
|
+
v-model="content"
|
|
207
|
+
class="vk-textarea"
|
|
208
|
+
placeholder="Write your reply"
|
|
209
|
+
/>
|
|
210
|
+
</div>
|
|
211
|
+
|
|
212
|
+
<div class="vk-field">
|
|
213
|
+
<label class="vk-label" for="reply-files">Attachments</label>
|
|
214
|
+
<input
|
|
215
|
+
id="reply-files"
|
|
216
|
+
class="vk-input"
|
|
217
|
+
type="file"
|
|
218
|
+
multiple
|
|
219
|
+
@change="files = Array.from(($event.target as HTMLInputElement).files || [])"
|
|
220
|
+
/>
|
|
221
|
+
</div>
|
|
222
|
+
|
|
223
|
+
<div class="vk-actions">
|
|
224
|
+
<button class="vk-button primary" type="submit" :disabled="isSending">
|
|
225
|
+
{{ isSending ? "Sending..." : "Send reply" }}
|
|
226
|
+
</button>
|
|
227
|
+
</div>
|
|
228
|
+
</form>
|
|
229
|
+
</template>
|
|
230
|
+
</div>
|
|
231
|
+
</div>
|
|
232
|
+
</div>
|
|
233
|
+
</template>
|
|
234
|
+
|
|
235
|
+
<style src="./vicket.css"></style>
|