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