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