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