@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.
Files changed (55) hide show
  1. package/README.md +52 -0
  2. package/bin/create-vicket-support.js +389 -0
  3. package/package.json +18 -0
  4. package/templates/next/src/app/api/vicket/[...path]/route.ts +59 -0
  5. package/templates/next/src/app/components/vicket/TicketDialog.tsx +514 -0
  6. package/templates/next/src/app/support/page.tsx +358 -0
  7. package/templates/next/src/app/ticket/page.tsx +483 -0
  8. package/templates/next/src/app/utils/vicket/api.ts +149 -0
  9. package/templates/next/src/app/utils/vicket/types.ts +85 -0
  10. package/templates/next/src/app/utils/vicket/utils.ts +49 -0
  11. package/templates/next/src/app/vicket.css +1325 -0
  12. package/templates/nuxt/app/assets/css/vicket.css +1325 -0
  13. package/templates/nuxt/app/components/VicketTicketDialog.vue +499 -0
  14. package/templates/nuxt/app/composables/useVicket.ts +274 -0
  15. package/templates/nuxt/app/pages/support.vue +303 -0
  16. package/templates/nuxt/app/pages/ticket.vue +434 -0
  17. package/templates/nuxt/server/api/vicket/[...path].ts +85 -0
  18. package/templates/sveltekit/src/lib/vicket/TicketDialog.svelte +459 -0
  19. package/templates/sveltekit/src/lib/vicket/api.ts +162 -0
  20. package/templates/sveltekit/src/lib/vicket/types.ts +87 -0
  21. package/templates/sveltekit/src/lib/vicket/utils.ts +55 -0
  22. package/templates/sveltekit/src/lib/vicket.css +1325 -0
  23. package/templates/sveltekit/src/routes/api/vicket/[...path]/+server.ts +77 -0
  24. package/templates/sveltekit/src/routes/support/+page.svelte +316 -0
  25. package/templates/sveltekit/src/routes/ticket/+page.svelte +418 -0
  26. package/templates-tailwind/next/src/app/api/vicket/init/route.ts +24 -0
  27. package/templates-tailwind/next/src/app/api/vicket/messages/route.ts +36 -0
  28. package/templates-tailwind/next/src/app/api/vicket/thread/route.ts +27 -0
  29. package/templates-tailwind/next/src/app/api/vicket/tickets/route.ts +37 -0
  30. package/templates-tailwind/next/src/app/support/page.tsx +5 -0
  31. package/templates-tailwind/next/src/app/ticket/page.tsx +10 -0
  32. package/templates-tailwind/next/src/components/vicket/support-page.tsx +359 -0
  33. package/templates-tailwind/next/src/components/vicket/ticket-dialog.tsx +306 -0
  34. package/templates-tailwind/next/src/components/vicket/ticket-page.tsx +425 -0
  35. package/templates-tailwind/next/src/lib/vicket.ts +257 -0
  36. package/templates-tailwind/nuxt/app/components/VicketSupportPage.vue +317 -0
  37. package/templates-tailwind/nuxt/app/components/VicketTicketDialog.vue +444 -0
  38. package/templates-tailwind/nuxt/app/components/VicketTicketPage.vue +449 -0
  39. package/templates-tailwind/nuxt/app/composables/use-vicket.ts +249 -0
  40. package/templates-tailwind/nuxt/app/pages/support.vue +3 -0
  41. package/templates-tailwind/nuxt/app/pages/ticket.vue +3 -0
  42. package/templates-tailwind/nuxt/server/api/vicket/init.get.ts +22 -0
  43. package/templates-tailwind/nuxt/server/api/vicket/messages.post.ts +56 -0
  44. package/templates-tailwind/nuxt/server/api/vicket/thread.get.ts +26 -0
  45. package/templates-tailwind/nuxt/server/api/vicket/tickets.post.ts +53 -0
  46. package/templates-tailwind/sveltekit/src/lib/vicket/SupportPage.svelte +395 -0
  47. package/templates-tailwind/sveltekit/src/lib/vicket/TicketDialog.svelte +406 -0
  48. package/templates-tailwind/sveltekit/src/lib/vicket/TicketPage.svelte +465 -0
  49. package/templates-tailwind/sveltekit/src/lib/vicket/index.ts +257 -0
  50. package/templates-tailwind/sveltekit/src/routes/api/vicket/init/+server.ts +22 -0
  51. package/templates-tailwind/sveltekit/src/routes/api/vicket/messages/+server.ts +40 -0
  52. package/templates-tailwind/sveltekit/src/routes/api/vicket/thread/+server.ts +25 -0
  53. package/templates-tailwind/sveltekit/src/routes/api/vicket/tickets/+server.ts +37 -0
  54. package/templates-tailwind/sveltekit/src/routes/support/+page.svelte +5 -0
  55. package/templates-tailwind/sveltekit/src/routes/ticket/+page.svelte +5 -0
@@ -0,0 +1,483 @@
1
+ "use client";
2
+
3
+ import { useSearchParams } from "next/navigation";
4
+ import { type FormEvent, useEffect, useMemo, useState } from "react";
5
+ import "../vicket.css";
6
+ import type { Message, TicketThread } from "../utils/vicket/types";
7
+ import { cn, sanitizeHtml, stripHtml, formatDate, isFileAnswer, formatAnswerText } from "../utils/vicket/utils";
8
+ import { AUTHOR_LABELS, fetchTicketThread, sendReply } from "../utils/vicket/api";
9
+
10
+ /* ---------------------------------------------- */
11
+ /* Skeleton loader */
12
+ /* ---------------------------------------------- */
13
+ function ThreadSkeleton() {
14
+ return (
15
+ <div className="vk-stack vk-animate-in">
16
+ {/* Header skeleton */}
17
+ <div>
18
+ <div className="vk-skeleton" aria-hidden="true">
19
+ &nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;
20
+ </div>
21
+ <div className="vk-compose-files">
22
+ <span className="vk-skeleton" aria-hidden="true">&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;</span>
23
+ <span className="vk-skeleton" aria-hidden="true">&nbsp;&nbsp;&nbsp;&nbsp;</span>
24
+ </div>
25
+ </div>
26
+ {/* Messages skeleton */}
27
+ <div className="vk-section-card">
28
+ <div className="vk-section-header">
29
+ <span className="vk-skeleton" aria-hidden="true">&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;</span>
30
+ </div>
31
+ {[1, 2, 3].map((i) => (
32
+ <div key={i} className="vk-message">
33
+ <div className="vk-avatar vk-skeleton" aria-hidden="true">&nbsp;</div>
34
+ <div className="vk-message-content">
35
+ <div className="vk-skeleton" aria-hidden="true">&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;</div>
36
+ <div className="vk-skeleton" aria-hidden="true">&nbsp;</div>
37
+ </div>
38
+ </div>
39
+ ))}
40
+ </div>
41
+ </div>
42
+ );
43
+ }
44
+
45
+ /* ---------------------------------------------- */
46
+ /* Alert component */
47
+ /* ---------------------------------------------- */
48
+ function Alert({
49
+ type,
50
+ message,
51
+ onDismiss,
52
+ }: {
53
+ type: "error" | "success";
54
+ message: string;
55
+ onDismiss?: () => void;
56
+ }) {
57
+ return (
58
+ <div
59
+ className={cn("vk-alert vk-slide-up", type === "error" ? "error" : "success")}
60
+ role="alert"
61
+ >
62
+ <span>{type === "error" ? "\u26A0" : "\u2713"}</span>
63
+ <span>{message}</span>
64
+ {onDismiss && (
65
+ <button
66
+ type="button"
67
+ onClick={onDismiss}
68
+ className="vk-alert-dismiss"
69
+ aria-label="Dismiss"
70
+ >
71
+ &#10005;
72
+ </button>
73
+ )}
74
+ </div>
75
+ );
76
+ }
77
+
78
+ /* ---------------------------------------------- */
79
+ /* Badge component */
80
+ /* ---------------------------------------------- */
81
+ function Badge({
82
+ label,
83
+ variant,
84
+ }: {
85
+ label: string;
86
+ variant?: "status" | "priority" | "id";
87
+ }) {
88
+ return (
89
+ <span className={cn("vk-badge", variant || "status")}>
90
+ {label}
91
+ </span>
92
+ );
93
+ }
94
+
95
+ /* ---------------------------------------------- */
96
+ /* Avatar */
97
+ /* ---------------------------------------------- */
98
+ function Avatar({ authorType }: { authorType: string }) {
99
+ return (
100
+ <div
101
+ className={cn("vk-avatar", `vk-avatar-${authorType}`)}
102
+ aria-hidden="true"
103
+ >
104
+ {(AUTHOR_LABELS[authorType] || "?")[0]}
105
+ </div>
106
+ );
107
+ }
108
+
109
+ /* ---------------------------------------------- */
110
+ /* Message item */
111
+ /* ---------------------------------------------- */
112
+ function MessageItem({ message }: { message: Message }) {
113
+ const isSystem = message.author_type === "system";
114
+ const isSupport = message.author_type === "user";
115
+
116
+ if (isSystem) {
117
+ return (
118
+ <div className="vk-message">
119
+ <Avatar authorType="system" />
120
+ <div className="vk-message-content">
121
+ <div className="vk-message-meta">
122
+ <span className="vk-author-name system">System</span>
123
+ <span className="vk-message-time">{formatDate(message.created_at)}</span>
124
+ </div>
125
+ <p className="vk-system-text">
126
+ {stripHtml(message.content)}
127
+ </p>
128
+ </div>
129
+ </div>
130
+ );
131
+ }
132
+
133
+ return (
134
+ <div className="vk-message">
135
+ <Avatar authorType={message.author_type} />
136
+ <div className="vk-message-content">
137
+ <div className="vk-message-meta">
138
+ <span className="vk-author-name">
139
+ {AUTHOR_LABELS[message.author_type] || message.author_type}
140
+ </span>
141
+ <span className="vk-message-time">{formatDate(message.created_at)}</span>
142
+ </div>
143
+ <div
144
+ className={cn(
145
+ "vk-message-bubble",
146
+ isSupport && "support",
147
+ )}
148
+ >
149
+ <div
150
+ className="vk-message-html"
151
+ dangerouslySetInnerHTML={{
152
+ __html: sanitizeHtml(message.content),
153
+ }}
154
+ />
155
+ </div>
156
+ {message.attachments && message.attachments.length > 0 && (
157
+ <div className="vk-attachments">
158
+ {message.attachments.map((attachment) => (
159
+ <a
160
+ className="vk-attachment"
161
+ href={attachment.url}
162
+ key={attachment.id}
163
+ rel="noopener noreferrer"
164
+ target="_blank"
165
+ >
166
+ &#128206; <span>{attachment.original_filename}</span>
167
+ </a>
168
+ ))}
169
+ </div>
170
+ )}
171
+ </div>
172
+ </div>
173
+ );
174
+ }
175
+
176
+ /* ---------------------------------------------- */
177
+ /* Main page */
178
+ /* ---------------------------------------------- */
179
+ export default function TicketPage() {
180
+ const searchParams = useSearchParams();
181
+ const token = searchParams.get("token") || "";
182
+
183
+ const [thread, setThread] = useState<TicketThread | null>(null);
184
+ const [content, setContent] = useState("");
185
+ const [files, setFiles] = useState<File[]>([]);
186
+ const [isLoading, setIsLoading] = useState(true);
187
+ const [isSending, setIsSending] = useState(false);
188
+ const [error, setError] = useState("");
189
+ const [success, setSuccess] = useState("");
190
+
191
+ const hasToken = useMemo(() => token.trim().length > 0, [token]);
192
+
193
+ const loadThread = async () => {
194
+ if (!hasToken) {
195
+ setIsLoading(false);
196
+ setError("Missing ticket token in URL.");
197
+ return;
198
+ }
199
+
200
+ setIsLoading(true);
201
+ setError("");
202
+ try {
203
+ const data = await fetchTicketThread(token);
204
+ setThread(data);
205
+ } catch (loadError) {
206
+ setError(loadError instanceof Error ? loadError.message : "Unexpected error.");
207
+ } finally {
208
+ setIsLoading(false);
209
+ }
210
+ };
211
+
212
+ useEffect(() => {
213
+ void loadThread();
214
+ // eslint-disable-next-line react-hooks/exhaustive-deps
215
+ }, [token]);
216
+
217
+ const firstReporterMessage = useMemo(() => {
218
+ if (!thread?.messages || thread.messages.length === 0) return null;
219
+ const sorted = [...thread.messages].sort(
220
+ (a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime(),
221
+ );
222
+ // Only treat as description if the very first message is from the reporter
223
+ return sorted[0].author_type === "reporter" ? sorted[0] : null;
224
+ }, [thread]);
225
+
226
+ const sortedMessages = useMemo(() => {
227
+ if (!thread?.messages) return [];
228
+ return [...thread.messages]
229
+ .sort((a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime())
230
+ .filter((m) => !firstReporterMessage || m.id !== firstReporterMessage.id);
231
+ }, [thread, firstReporterMessage]);
232
+
233
+ const summaryAnswers = useMemo(() => {
234
+ if (!thread?.answers) return [];
235
+ return thread.answers.filter((answer) => {
236
+ if (answer.attachments && answer.attachments.length > 0) return true;
237
+ if (answer.answer && answer.answer.trim().length > 0) return true;
238
+ return false;
239
+ });
240
+ }, [thread]);
241
+
242
+ const removeFile = (index: number) => {
243
+ setFiles((prev) => prev.filter((_, i) => i !== index));
244
+ };
245
+
246
+ const onSubmitReply = async (event: FormEvent<HTMLFormElement>) => {
247
+ event.preventDefault();
248
+ setError("");
249
+ setSuccess("");
250
+
251
+ if (!content.trim() && files.length === 0) {
252
+ setError("Reply content is required.");
253
+ return;
254
+ }
255
+
256
+ if (!hasToken) {
257
+ setError("Missing ticket token.");
258
+ return;
259
+ }
260
+
261
+ setIsSending(true);
262
+ try {
263
+ await sendReply(token, content.trim(), files);
264
+ setContent("");
265
+ setFiles([]);
266
+ setSuccess("Reply sent.");
267
+ await loadThread();
268
+ } catch (replyError) {
269
+ setError(replyError instanceof Error ? replyError.message : "Unexpected error.");
270
+ } finally {
271
+ setIsSending(false);
272
+ }
273
+ };
274
+
275
+ return (
276
+ <div className="vk-shell">
277
+ <div className="vk-page vk-animate-in">
278
+ {/* Back link */}
279
+ <div className="vk-ticket-badges">
280
+ <a href="/support" className="vk-back-link">
281
+ &#8592; Back to support
282
+ </a>
283
+ </div>
284
+
285
+ {/* Alerts */}
286
+ {error && (
287
+ <Alert type="error" message={error} onDismiss={() => setError("")} />
288
+ )}
289
+ {success && (
290
+ <Alert type="success" message={success} onDismiss={() => setSuccess("")} />
291
+ )}
292
+
293
+ {/* Loading */}
294
+ {isLoading && <ThreadSkeleton />}
295
+
296
+ {/* Thread */}
297
+ {!isLoading && thread && (
298
+ <div className="vk-stack">
299
+ {/* Header */}
300
+ <div>
301
+ <div className="vk-ticket-header">
302
+ <h1 className="vk-ticket-title">{thread.title}</h1>
303
+ {thread.id && (
304
+ <Badge label={`#${thread.id.slice(0, 8)}`} variant="id" />
305
+ )}
306
+ </div>
307
+ {((thread.status?.label && thread.status.label.toLowerCase() !== "open") ||
308
+ (thread.priority?.label && thread.priority.label.toLowerCase() !== "low")) && (
309
+ <div className="vk-ticket-badges">
310
+ {thread.status?.label && thread.status.label.toLowerCase() !== "open" && (
311
+ <Badge label={thread.status.label} variant="status" />
312
+ )}
313
+ {thread.priority?.label && thread.priority.label.toLowerCase() !== "low" && (
314
+ <Badge label={thread.priority.label} variant="priority" />
315
+ )}
316
+ </div>
317
+ )}
318
+ </div>
319
+
320
+ {/* Summary (ticket form answers) */}
321
+ {summaryAnswers.length > 0 && (
322
+ <div className="vk-section-card">
323
+ <div className="vk-section-header">
324
+ <span className="vk-section-header-icon">&#128221;</span>
325
+ <h2>Summary</h2>
326
+ </div>
327
+ <div className="vk-section-body">
328
+ <div className="vk-summary-list">
329
+ {summaryAnswers.map((answer) => (
330
+ <div key={answer.id} className="vk-summary-item">
331
+ <p className="vk-summary-label">{answer.question_label || "Question"}</p>
332
+ {answer.attachments && answer.attachments.length > 0 ? (
333
+ <div className="vk-attachments">
334
+ {answer.attachments.map((attachment) => (
335
+ <a
336
+ className="vk-attachment"
337
+ href={attachment.url}
338
+ key={attachment.id}
339
+ rel="noopener noreferrer"
340
+ target="_blank"
341
+ >
342
+ &#128206; <span>{attachment.original_filename}</span>
343
+ </a>
344
+ ))}
345
+ </div>
346
+ ) : isFileAnswer(answer.answer) ? (
347
+ <p className="vk-summary-value muted">File uploaded</p>
348
+ ) : (
349
+ <p className="vk-summary-value">
350
+ {formatAnswerText(answer.answer) || "-"}
351
+ </p>
352
+ )}
353
+ </div>
354
+ ))}
355
+ </div>
356
+ </div>
357
+ </div>
358
+ )}
359
+
360
+ {/* Description (first reporter message) */}
361
+ {firstReporterMessage && (
362
+ <div className="vk-section-card">
363
+ <div className="vk-section-header">
364
+ <span className="vk-section-header-icon">&#128196;</span>
365
+ <h2>Description</h2>
366
+ </div>
367
+ <div className="vk-section-body">
368
+ <div
369
+ className="vk-description-content vk-message-html"
370
+ dangerouslySetInnerHTML={{
371
+ __html: sanitizeHtml(firstReporterMessage.content),
372
+ }}
373
+ />
374
+ {firstReporterMessage.attachments && firstReporterMessage.attachments.length > 0 && (
375
+ <div className="vk-attachments">
376
+ {firstReporterMessage.attachments.map((att) => (
377
+ <a
378
+ key={att.id}
379
+ href={att.url}
380
+ target="_blank"
381
+ rel="noopener noreferrer"
382
+ className="vk-attachment"
383
+ >
384
+ &#128206; <span>{att.original_filename}</span>
385
+ </a>
386
+ ))}
387
+ </div>
388
+ )}
389
+ </div>
390
+ </div>
391
+ )}
392
+
393
+ {/* Comments card */}
394
+ <div className="vk-section-card">
395
+ <div className="vk-section-header">
396
+ <span className="vk-section-header-icon">&#128172;</span>
397
+ <h2>Comments</h2>
398
+ </div>
399
+
400
+ {/* Compose area */}
401
+ <div className="vk-compose">
402
+ <form className="vk-stack" onSubmit={onSubmitReply}>
403
+ <textarea
404
+ className="vk-textarea"
405
+ value={content}
406
+ onChange={(e) => setContent(e.target.value)}
407
+ placeholder="Write your reply..."
408
+ />
409
+
410
+ <div className="vk-compose-row">
411
+ {/* File input */}
412
+ <div className="vk-compose-files">
413
+ <label className="vk-browse-btn">
414
+ &#128206; Browse files
415
+ <input
416
+ type="file"
417
+ multiple
418
+ onChange={(e) => {
419
+ const newFiles = Array.from(e.target.files || []);
420
+ setFiles((prev) => [...prev, ...newFiles]);
421
+ e.target.value = "";
422
+ }}
423
+ />
424
+ </label>
425
+ {files.map((file, i) => (
426
+ <span className="vk-file-chip" key={`${file.name}-${i}`}>
427
+ &#128206;
428
+ <span className="vk-file-chip-name">{file.name}</span>
429
+ <button
430
+ type="button"
431
+ onClick={() => removeFile(i)}
432
+ aria-label={`Remove ${file.name}`}
433
+ >
434
+ &#10005;
435
+ </button>
436
+ </span>
437
+ ))}
438
+ </div>
439
+
440
+ {/* Send */}
441
+ <button
442
+ className="vk-button primary"
443
+ disabled={isSending}
444
+ type="submit"
445
+ >
446
+ {isSending ? (
447
+ <>
448
+ <span className="vk-spinner" />
449
+ Sending...
450
+ </>
451
+ ) : (
452
+ "Send"
453
+ )}
454
+ </button>
455
+ </div>
456
+ </form>
457
+ </div>
458
+
459
+ {/* Message list */}
460
+ {sortedMessages.length === 0 ? (
461
+ <div className="vk-empty-state">
462
+ <div className="vk-empty-icon">&#128172;</div>
463
+ <p className="vk-empty-text">
464
+ No messages yet. Be the first to reply!
465
+ </p>
466
+ </div>
467
+ ) : (
468
+ <div className="vk-message-list">
469
+ {sortedMessages.map((message, index) => (
470
+ <div key={message.id}>
471
+ {index > 0 && <hr className="vk-message-divider" />}
472
+ <MessageItem message={message} />
473
+ </div>
474
+ ))}
475
+ </div>
476
+ )}
477
+ </div>
478
+ </div>
479
+ )}
480
+ </div>
481
+ </div>
482
+ );
483
+ }
@@ -0,0 +1,149 @@
1
+ /* ---------------------------------------------- */
2
+ /* API functions and constants for Vicket pages */
3
+ /* ---------------------------------------------- */
4
+
5
+ import type { FormValues, SupportInitResponse, TicketThread } from "./types";
6
+
7
+ export const PROXY_BASE = "/api/vicket";
8
+
9
+ export const initialFormValues: FormValues = { email: "", title: "", answers: {} };
10
+
11
+ export const AUTHOR_LABELS: Record<string, string> = {
12
+ reporter: "You",
13
+ user: "Support",
14
+ system: "System",
15
+ };
16
+
17
+ export async function fetchSupportInit(): Promise<NonNullable<SupportInitResponse["data"]>> {
18
+ const response = await fetch(`${PROXY_BASE}/init`, {
19
+ method: "GET",
20
+ cache: "no-store",
21
+ headers: { "Content-Type": "application/json" },
22
+ });
23
+ const payload = (await response.json()) as SupportInitResponse;
24
+
25
+ if (!response.ok || !payload?.success || !payload?.data) {
26
+ throw new Error(payload?.error || "Failed to load support data.");
27
+ }
28
+
29
+ return payload.data;
30
+ }
31
+
32
+ export async function createTicket(body: {
33
+ email: string;
34
+ title: string;
35
+ templateId: string;
36
+ answers: Record<string, unknown>;
37
+ hasFiles: boolean;
38
+ fileQuestionIds: string[];
39
+ }): Promise<{ emailLimitReached?: boolean; warning?: string }> {
40
+ const payload = {
41
+ email: body.email,
42
+ title: body.title,
43
+ templateId: body.templateId,
44
+ answers: { ...body.answers },
45
+ };
46
+
47
+ let response: Response;
48
+ if (body.hasFiles) {
49
+ const formData = new FormData();
50
+ const normalizedAnswers: Record<string, unknown> = {};
51
+ for (const [questionId, answer] of Object.entries(payload.answers)) {
52
+ if (Array.isArray(answer) && answer.length > 0 && answer[0] instanceof File) {
53
+ (answer as File[]).forEach((f) => formData.append(`files[${questionId}]`, f));
54
+ normalizedAnswers[questionId] = "__isFile:true";
55
+ } else if (answer instanceof File) {
56
+ formData.append(`files[${questionId}]`, answer);
57
+ normalizedAnswers[questionId] = "__isFile:true";
58
+ } else {
59
+ normalizedAnswers[questionId] = answer;
60
+ }
61
+ }
62
+ formData.append("data", JSON.stringify({ ...payload, answers: normalizedAnswers }));
63
+ response = await fetch(`${PROXY_BASE}/tickets`, {
64
+ method: "POST",
65
+ body: formData,
66
+ });
67
+ } else {
68
+ response = await fetch(`${PROXY_BASE}/tickets`, {
69
+ method: "POST",
70
+ headers: { "Content-Type": "application/json" },
71
+ body: JSON.stringify(payload),
72
+ });
73
+ }
74
+
75
+ const responsePayload = (await response.json()) as {
76
+ error?: string;
77
+ success?: boolean;
78
+ data?: { email_limit_reached?: boolean; warning?: string };
79
+ };
80
+ if (!response.ok || !responsePayload?.success) {
81
+ throw new Error(responsePayload?.error || "Failed to create ticket.");
82
+ }
83
+
84
+ return {
85
+ emailLimitReached: responsePayload.data?.email_limit_reached ?? false,
86
+ warning: responsePayload.data?.warning,
87
+ };
88
+ }
89
+
90
+ export async function fetchTicketThread(token: string): Promise<TicketThread> {
91
+ const response = await fetch(
92
+ `${PROXY_BASE}/ticket?token=${encodeURIComponent(token)}`,
93
+ {
94
+ method: "GET",
95
+ cache: "no-store",
96
+ headers: { "Content-Type": "application/json" },
97
+ },
98
+ );
99
+ const payload = (await response.json()) as {
100
+ success?: boolean;
101
+ error?: string;
102
+ error_code?: string;
103
+ data?: TicketThread;
104
+ };
105
+
106
+ if (!response.ok || !payload?.success || !payload?.data) {
107
+ if (payload?.error_code === "ticket-link-expired") {
108
+ throw new Error("This link has expired. A new secure link has been sent to your email.");
109
+ }
110
+ throw new Error(payload?.error || "Failed to load ticket.");
111
+ }
112
+
113
+ return payload.data;
114
+ }
115
+
116
+ export async function sendReply(token: string, content: string, files: File[]): Promise<void> {
117
+ const url = `${PROXY_BASE}/ticket/messages?token=${encodeURIComponent(token)}`;
118
+
119
+ let response: Response;
120
+ if (files.length > 0) {
121
+ const formData = new FormData();
122
+ formData.append("data", JSON.stringify({ content }));
123
+ for (const file of files) {
124
+ formData.append("files", file);
125
+ }
126
+ response = await fetch(url, {
127
+ method: "POST",
128
+ body: formData,
129
+ });
130
+ } else {
131
+ response = await fetch(url, {
132
+ method: "POST",
133
+ headers: { "Content-Type": "application/json" },
134
+ body: JSON.stringify({ content }),
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("This link has expired. A new secure link has been sent to your email.");
146
+ }
147
+ throw new Error(payload?.error || "Failed to send reply.");
148
+ }
149
+ }