@vicket/create-support 1.1.1 → 1.1.2

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 (71) hide show
  1. package/bin/create-vicket-support.js +429 -389
  2. package/package.json +1 -1
  3. package/templates/next/src/app/api/vicket/[...path]/route.ts +2 -55
  4. package/templates/next/src/app/components/vicket/ReplyForm.tsx +154 -0
  5. package/templates/next/src/app/components/vicket/SupportContent.tsx +298 -0
  6. package/templates/next/src/app/components/vicket/TicketDialog.tsx +3 -3
  7. package/templates/next/src/app/support/page.tsx +27 -353
  8. package/templates/next/src/app/ticket/page.tsx +110 -325
  9. package/templates/next/src/app/vicket.css +1325 -1325
  10. package/templates/nuxt/app/assets/css/vicket.css +1325 -1325
  11. package/templates/nuxt/app/components/VicketReplyForm.vue +154 -0
  12. package/templates/nuxt/app/components/VicketSupportContent.vue +255 -0
  13. package/templates/nuxt/app/components/VicketTicketDialog.vue +2 -2
  14. package/templates/nuxt/app/pages/support.vue +7 -293
  15. package/templates/nuxt/app/pages/ticket.vue +36 -178
  16. package/templates/nuxt/server/api/vicket/[...path].ts +2 -85
  17. package/templates/sveltekit/src/lib/vicket/ReplyForm.svelte +134 -0
  18. package/templates/sveltekit/src/lib/vicket/SupportContent.svelte +263 -0
  19. package/templates/sveltekit/src/lib/vicket/TicketDialog.svelte +457 -459
  20. package/templates/sveltekit/src/lib/vicket.css +1325 -1325
  21. package/templates/sveltekit/src/routes/api/vicket/[...path]/+server.ts +2 -76
  22. package/templates/sveltekit/src/routes/support/+page.server.ts +13 -0
  23. package/templates/sveltekit/src/routes/support/+page.svelte +3 -312
  24. package/templates/sveltekit/src/routes/ticket/+page.server.ts +19 -0
  25. package/templates/sveltekit/src/routes/ticket/+page.svelte +13 -188
  26. package/templates-tailwind/next/src/app/api/vicket/[...path]/route.ts +6 -0
  27. package/templates-tailwind/next/src/app/support/page.tsx +33 -3
  28. package/templates-tailwind/next/src/app/ticket/page.tsx +249 -6
  29. package/templates-tailwind/next/src/components/vicket/reply-form.tsx +113 -0
  30. package/templates-tailwind/next/src/components/vicket/support-content.tsx +265 -0
  31. package/templates-tailwind/next/src/components/vicket/ticket-dialog.tsx +2 -2
  32. package/templates-tailwind/nuxt/app/components/VicketReplyForm.vue +169 -0
  33. package/templates-tailwind/nuxt/app/components/{VicketSupportPage.vue → VicketSupportContent.vue} +275 -317
  34. package/templates-tailwind/nuxt/app/components/VicketTicketDialog.vue +3 -0
  35. package/templates-tailwind/nuxt/app/pages/support.vue +10 -1
  36. package/templates-tailwind/nuxt/app/pages/ticket.vue +298 -1
  37. package/templates-tailwind/nuxt/server/api/vicket/[...path].ts +2 -0
  38. package/templates-tailwind/sveltekit/src/lib/vicket/ReplyForm.svelte +127 -0
  39. package/templates-tailwind/sveltekit/src/lib/vicket/{SupportPage.svelte → SupportContent.svelte} +9 -71
  40. package/templates-tailwind/sveltekit/src/lib/vicket/TicketDialog.svelte +405 -406
  41. package/templates-tailwind/sveltekit/src/routes/api/vicket/[...path]/+server.ts +3 -0
  42. package/templates-tailwind/sveltekit/src/routes/support/+page.server.ts +13 -0
  43. package/templates-tailwind/sveltekit/src/routes/support/+page.svelte +4 -2
  44. package/templates-tailwind/sveltekit/src/routes/ticket/+page.server.ts +19 -0
  45. package/templates-tailwind/sveltekit/src/routes/ticket/+page.svelte +292 -2
  46. package/templates/next/src/app/utils/vicket/api.ts +0 -149
  47. package/templates/next/src/app/utils/vicket/types.ts +0 -85
  48. package/templates/next/src/app/utils/vicket/utils.ts +0 -49
  49. package/templates/nuxt/app/composables/useVicket.ts +0 -274
  50. package/templates/sveltekit/src/lib/vicket/api.ts +0 -162
  51. package/templates/sveltekit/src/lib/vicket/types.ts +0 -87
  52. package/templates/sveltekit/src/lib/vicket/utils.ts +0 -55
  53. package/templates-tailwind/next/src/app/api/vicket/init/route.ts +0 -24
  54. package/templates-tailwind/next/src/app/api/vicket/messages/route.ts +0 -36
  55. package/templates-tailwind/next/src/app/api/vicket/thread/route.ts +0 -27
  56. package/templates-tailwind/next/src/app/api/vicket/tickets/route.ts +0 -37
  57. package/templates-tailwind/next/src/components/vicket/support-page.tsx +0 -359
  58. package/templates-tailwind/next/src/components/vicket/ticket-page.tsx +0 -425
  59. package/templates-tailwind/next/src/lib/vicket.ts +0 -257
  60. package/templates-tailwind/nuxt/app/components/VicketTicketPage.vue +0 -449
  61. package/templates-tailwind/nuxt/app/composables/use-vicket.ts +0 -249
  62. package/templates-tailwind/nuxt/server/api/vicket/init.get.ts +0 -22
  63. package/templates-tailwind/nuxt/server/api/vicket/messages.post.ts +0 -56
  64. package/templates-tailwind/nuxt/server/api/vicket/thread.get.ts +0 -26
  65. package/templates-tailwind/nuxt/server/api/vicket/tickets.post.ts +0 -53
  66. package/templates-tailwind/sveltekit/src/lib/vicket/TicketPage.svelte +0 -465
  67. package/templates-tailwind/sveltekit/src/lib/vicket/index.ts +0 -257
  68. package/templates-tailwind/sveltekit/src/routes/api/vicket/init/+server.ts +0 -22
  69. package/templates-tailwind/sveltekit/src/routes/api/vicket/messages/+server.ts +0 -40
  70. package/templates-tailwind/sveltekit/src/routes/api/vicket/thread/+server.ts +0 -25
  71. package/templates-tailwind/sveltekit/src/routes/api/vicket/tickets/+server.ts +0 -37
@@ -1,276 +1,55 @@
1
- "use client";
2
-
3
- import { useSearchParams } from "next/navigation";
4
- import { type FormEvent, useEffect, useMemo, useState } from "react";
1
+ import { createServerClient } from "vicket/server";
2
+ import type { TicketThread } from "vicket";
3
+ import { cn, sanitizeHtml, stripHtml, formatDate, isFileAnswer, formatAnswerText, AUTHOR_LABELS } from "vicket";
5
4
  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
- }
5
+ import ReplyForm from "../components/vicket/ReplyForm";
94
6
 
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
- }
7
+ type Props = {
8
+ searchParams: Promise<{ [key: string]: string | string[] | undefined }>;
9
+ };
108
10
 
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";
11
+ export default async function TicketPage({ searchParams }: Props) {
12
+ const { token } = await searchParams;
13
+ const tokenStr = typeof token === "string" ? token : "";
115
14
 
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
- }
15
+ let thread: TicketThread | null = null;
16
+ let fetchError = "";
132
17
 
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("");
18
+ if (!tokenStr.trim()) {
19
+ fetchError = "Missing ticket token in URL.";
20
+ } else {
202
21
  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);
22
+ const vicket = createServerClient();
23
+ thread = await vicket.fetchThread(tokenStr);
24
+ } catch (e) {
25
+ fetchError = e instanceof Error ? e.message : "Failed to load ticket.";
209
26
  }
210
- };
211
-
212
- useEffect(() => {
213
- void loadThread();
214
- // eslint-disable-next-line react-hooks/exhaustive-deps
215
- }, [token]);
27
+ }
216
28
 
217
- const firstReporterMessage = useMemo(() => {
29
+ /* Derive data from thread */
30
+ const firstReporterMessage = (() => {
218
31
  if (!thread?.messages || thread.messages.length === 0) return null;
219
32
  const sorted = [...thread.messages].sort(
220
33
  (a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime(),
221
34
  );
222
- // Only treat as description if the very first message is from the reporter
223
35
  return sorted[0].author_type === "reporter" ? sorted[0] : null;
224
- }, [thread]);
36
+ })();
225
37
 
226
- const sortedMessages = useMemo(() => {
38
+ const sortedMessages = (() => {
227
39
  if (!thread?.messages) return [];
228
40
  return [...thread.messages]
229
41
  .sort((a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime())
230
42
  .filter((m) => !firstReporterMessage || m.id !== firstReporterMessage.id);
231
- }, [thread, firstReporterMessage]);
43
+ })();
232
44
 
233
- const summaryAnswers = useMemo(() => {
45
+ const summaryAnswers = (() => {
234
46
  if (!thread?.answers) return [];
235
47
  return thread.answers.filter((answer) => {
236
48
  if (answer.attachments && answer.attachments.length > 0) return true;
237
49
  if (answer.answer && answer.answer.trim().length > 0) return true;
238
50
  return false;
239
51
  });
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
- };
52
+ })();
274
53
 
275
54
  return (
276
55
  <div className="vk-shell">
@@ -282,36 +61,42 @@ export default function TicketPage() {
282
61
  </a>
283
62
  </div>
284
63
 
285
- {/* Alerts */}
286
- {error && (
287
- <Alert type="error" message={error} onDismiss={() => setError("")} />
288
- )}
289
- {success && (
290
- <Alert type="success" message={success} onDismiss={() => setSuccess("")} />
64
+ {/* Error */}
65
+ {fetchError && (
66
+ <div
67
+ className={cn("vk-alert vk-slide-up", "error")}
68
+ role="alert"
69
+ >
70
+ <span>{"\u26A0"}</span>
71
+ <span>{fetchError}</span>
72
+ </div>
291
73
  )}
292
74
 
293
- {/* Loading */}
294
- {isLoading && <ThreadSkeleton />}
295
-
296
75
  {/* Thread */}
297
- {!isLoading && thread && (
76
+ {thread && (
298
77
  <div className="vk-stack">
299
78
  {/* Header */}
300
79
  <div>
301
80
  <div className="vk-ticket-header">
302
81
  <h1 className="vk-ticket-title">{thread.title}</h1>
303
82
  {thread.id && (
304
- <Badge label={`#${thread.id.slice(0, 8)}`} variant="id" />
83
+ <span className={cn("vk-badge", "id")}>
84
+ {`#${thread.id.slice(0, 8)}`}
85
+ </span>
305
86
  )}
306
87
  </div>
307
88
  {((thread.status?.label && thread.status.label.toLowerCase() !== "open") ||
308
89
  (thread.priority?.label && thread.priority.label.toLowerCase() !== "low")) && (
309
90
  <div className="vk-ticket-badges">
310
91
  {thread.status?.label && thread.status.label.toLowerCase() !== "open" && (
311
- <Badge label={thread.status.label} variant="status" />
92
+ <span className={cn("vk-badge", "status")}>
93
+ {thread.status.label}
94
+ </span>
312
95
  )}
313
96
  {thread.priority?.label && thread.priority.label.toLowerCase() !== "low" && (
314
- <Badge label={thread.priority.label} variant="priority" />
97
+ <span className={cn("vk-badge", "priority")}>
98
+ {thread.priority.label}
99
+ </span>
315
100
  )}
316
101
  </div>
317
102
  )}
@@ -397,64 +182,8 @@ export default function TicketPage() {
397
182
  <h2>Comments</h2>
398
183
  </div>
399
184
 
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>
185
+ {/* Reply form (client component) */}
186
+ <ReplyForm token={tokenStr} />
458
187
 
459
188
  {/* Message list */}
460
189
  {sortedMessages.length === 0 ? (
@@ -466,12 +195,68 @@ export default function TicketPage() {
466
195
  </div>
467
196
  ) : (
468
197
  <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
- ))}
198
+ {sortedMessages.map((message, index) => {
199
+ const isSystem = message.author_type === "system";
200
+ const isSupport = message.author_type === "user";
201
+
202
+ return (
203
+ <div key={message.id}>
204
+ {index > 0 && <hr className="vk-message-divider" />}
205
+ <div className="vk-message">
206
+ <div
207
+ className={cn("vk-avatar", `vk-avatar-${message.author_type}`)}
208
+ aria-hidden="true"
209
+ >
210
+ {(AUTHOR_LABELS[message.author_type] || "?")[0]}
211
+ </div>
212
+ <div className="vk-message-content">
213
+ <div className="vk-message-meta">
214
+ <span className={cn("vk-author-name", isSystem && "system")}>
215
+ {isSystem ? "System" : (AUTHOR_LABELS[message.author_type] || message.author_type)}
216
+ </span>
217
+ <span className="vk-message-time">{formatDate(message.created_at)}</span>
218
+ </div>
219
+ {isSystem ? (
220
+ <p className="vk-system-text">
221
+ {stripHtml(message.content)}
222
+ </p>
223
+ ) : (
224
+ <>
225
+ <div
226
+ className={cn(
227
+ "vk-message-bubble",
228
+ isSupport && "support",
229
+ )}
230
+ >
231
+ <div
232
+ className="vk-message-html"
233
+ dangerouslySetInnerHTML={{
234
+ __html: sanitizeHtml(message.content),
235
+ }}
236
+ />
237
+ </div>
238
+ {message.attachments && message.attachments.length > 0 && (
239
+ <div className="vk-attachments">
240
+ {message.attachments.map((attachment) => (
241
+ <a
242
+ className="vk-attachment"
243
+ href={attachment.url}
244
+ key={attachment.id}
245
+ rel="noopener noreferrer"
246
+ target="_blank"
247
+ >
248
+ &#128206; <span>{attachment.original_filename}</span>
249
+ </a>
250
+ ))}
251
+ </div>
252
+ )}
253
+ </>
254
+ )}
255
+ </div>
256
+ </div>
257
+ </div>
258
+ );
259
+ })}
475
260
  </div>
476
261
  )}
477
262
  </div>