@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
@@ -0,0 +1,265 @@
1
+ "use client";
2
+
3
+ import { useMemo, useState } from "react";
4
+ import type { Template, Article, Faq } from "vicket";
5
+ import { sanitizeHtml, stripHtml, cn } from "vicket";
6
+ import TicketDialog from "./ticket-dialog";
7
+
8
+ /* ── Sub-components ──────────────────────────────── */
9
+
10
+ function FaqItem({ question, answer }: { question: string; answer: string }) {
11
+ const [open, setOpen] = useState(false);
12
+
13
+ return (
14
+ <div className="rounded-xl border border-slate-200 bg-white/50 transition-colors hover:bg-white/80">
15
+ <button
16
+ type="button"
17
+ onClick={() => setOpen(!open)}
18
+ className={cn(
19
+ "flex w-full cursor-pointer items-center justify-between gap-3 border-none bg-transparent px-4 py-3.5 text-left text-sm font-semibold text-slate-900",
20
+ open && "text-blue-600",
21
+ )}
22
+ aria-expanded={open}
23
+ >
24
+ {question}
25
+ <span className={cn("shrink-0 text-slate-500 transition-transform duration-200", open && "rotate-180")}>
26
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round"><path d="m6 9 6 6 6-6" /></svg>
27
+ </span>
28
+ </button>
29
+ <div className={cn("grid grid-rows-[0fr] transition-[grid-template-rows] duration-250", open && "grid-rows-[1fr]")}>
30
+ <div className="overflow-hidden">
31
+ <div className="whitespace-pre-wrap px-4 pb-4 text-sm leading-relaxed text-slate-500">
32
+ {answer}
33
+ </div>
34
+ </div>
35
+ </div>
36
+ </div>
37
+ );
38
+ }
39
+
40
+ /* ── Inline SVG icon paths ────────────────────── */
41
+ const svgArticle = <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8Z" /><path d="M14 2v6h6" /><path d="M16 13H8" /><path d="M16 17H8" /><path d="M10 9H8" /></svg>;
42
+ const svgArticleSmall = <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8Z" /><path d="M14 2v6h6" /><path d="M16 13H8" /><path d="M16 17H8" /><path d="M10 9H8" /></svg>;
43
+ const svgHelpCircle = <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="10" /><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3" /><path d="M12 17h.01" /></svg>;
44
+ const svgMessageCircle16 = <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round"><path d="M7.9 20A9 9 0 1 0 4 16.1L2 22Z" /></svg>;
45
+ const svgMessageCircle22 = <svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round"><path d="M7.9 20A9 9 0 1 0 4 16.1L2 22Z" /></svg>;
46
+ const svgSearch = <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round"><circle cx="11" cy="11" r="8" /><path d="m21 21-4.3-4.3" /></svg>;
47
+ const svgArrowLeft = <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round"><path d="m12 19-7-7 7-7" /><path d="M19 12H5" /></svg>;
48
+
49
+ /* ── Tailwind classes for dangerouslySetInnerHTML content ── */
50
+ const htmlContentClasses = "[&_p]:mb-2 [&_p:last-child]:mb-0 [&_a]:text-blue-600 [&_a]:underline [&_ul]:my-1 [&_ul]:pl-6 [&_ol]:my-1 [&_ol]:pl-6 [&_pre]:overflow-x-auto [&_pre]:rounded-md [&_pre]:bg-slate-50 [&_pre]:p-3 [&_pre]:text-xs [&_code]:font-mono [&_code]:text-[0.85em]";
51
+
52
+ /* ── Main component ──────────────────────────────── */
53
+
54
+ export default function SupportContent({
55
+ templates,
56
+ articles,
57
+ faqs,
58
+ websiteName,
59
+ initialError,
60
+ }: {
61
+ templates: Template[];
62
+ articles: Article[];
63
+ faqs: Faq[];
64
+ websiteName: string;
65
+ initialError: string;
66
+ }) {
67
+ const [searchQuery, setSearchQuery] = useState("");
68
+ const [dialogOpen, setDialogOpen] = useState(false);
69
+ const [selectedArticle, setSelectedArticle] = useState<Article | null>(null);
70
+ const [error, setError] = useState(initialError);
71
+
72
+ const filteredArticles = useMemo(() => {
73
+ if (!searchQuery.trim()) return articles;
74
+ const q = searchQuery.toLowerCase();
75
+ return articles.filter(
76
+ (a) => a.title.toLowerCase().includes(q) || stripHtml(a.content).toLowerCase().includes(q),
77
+ );
78
+ }, [articles, searchQuery]);
79
+
80
+ const filteredFaqs = useMemo(() => {
81
+ if (!searchQuery.trim()) return faqs;
82
+ const q = searchQuery.toLowerCase();
83
+ return faqs.filter(
84
+ (f) => f.question.toLowerCase().includes(q) || f.answer.toLowerCase().includes(q),
85
+ );
86
+ }, [faqs, searchQuery]);
87
+
88
+ const hasContent = articles.length > 0 || faqs.length > 0;
89
+ const hasResults = filteredArticles.length > 0 || filteredFaqs.length > 0;
90
+
91
+ /* ── Article viewer ── */
92
+ if (selectedArticle) {
93
+ return (
94
+ <div className="mx-auto max-w-5xl px-6">
95
+ <div className="flex flex-col gap-4 pb-8 pt-14 md:flex-row md:items-center md:justify-between">
96
+ <div>
97
+ <h1 className="m-0 text-2xl font-bold tracking-tight text-slate-900 md:text-3xl">{websiteName}</h1>
98
+ <p className="mt-1.5 text-slate-500">How can we help you today?</p>
99
+ </div>
100
+ {templates.length > 0 && (
101
+ <button
102
+ type="button"
103
+ className="inline-flex shrink-0 items-center gap-2 !rounded-full border-none bg-blue-600 !px-7 !py-3 text-sm font-semibold text-white cursor-pointer transition-all hover:bg-blue-700 hover:-translate-y-px hover:shadow-lg active:translate-y-0"
104
+ onClick={() => setDialogOpen(true)}
105
+ >
106
+ {svgMessageCircle16}
107
+ Contact Support
108
+ </button>
109
+ )}
110
+ </div>
111
+
112
+ <div className="pb-16">
113
+ <button
114
+ type="button"
115
+ onClick={() => setSelectedArticle(null)}
116
+ className="-ml-2 mb-4 inline-flex cursor-pointer items-center gap-1.5 rounded-lg border-none bg-transparent px-2 py-1 text-sm font-medium text-slate-500 transition-colors hover:bg-white hover:text-slate-900"
117
+ >
118
+ {svgArrowLeft}
119
+ Back to articles
120
+ </button>
121
+
122
+ <div className="rounded-2xl border border-slate-200 bg-white p-6 shadow-sm md:p-8">
123
+ <h2 className="m-0 text-xl font-bold tracking-tight text-slate-900 md:text-2xl">{selectedArticle.title}</h2>
124
+ <div
125
+ className={cn("mt-5 text-sm leading-relaxed text-slate-500", htmlContentClasses)}
126
+ dangerouslySetInnerHTML={{ __html: sanitizeHtml(selectedArticle.content) }}
127
+ />
128
+ </div>
129
+ </div>
130
+
131
+ <TicketDialog open={dialogOpen} onClose={() => setDialogOpen(false)} templates={templates} />
132
+ </div>
133
+ );
134
+ }
135
+
136
+ /* ── Main view ── */
137
+ return (
138
+ <div className="mx-auto max-w-5xl px-6">
139
+ <div className="flex flex-col gap-4 pb-8 pt-14 md:flex-row md:items-center md:justify-between">
140
+ <div>
141
+ <h1 className="m-0 text-2xl font-bold tracking-tight text-slate-900 md:text-3xl">{websiteName}</h1>
142
+ <p className="mt-1.5 text-slate-500">How can we help you today?</p>
143
+ </div>
144
+ {templates.length > 0 && (
145
+ <div className="flex items-center gap-3">
146
+ <span className="hidden text-sm text-slate-500 md:block">Can&apos;t find what you&apos;re looking for?</span>
147
+ <button
148
+ type="button"
149
+ className="inline-flex shrink-0 items-center gap-2 !rounded-full border-none bg-blue-600 !px-7 !py-3 text-sm font-semibold text-white cursor-pointer transition-all hover:bg-blue-700 hover:-translate-y-px hover:shadow-lg active:translate-y-0"
150
+ onClick={() => setDialogOpen(true)}
151
+ >
152
+ {svgMessageCircle16}
153
+ Contact Support
154
+ </button>
155
+ </div>
156
+ )}
157
+ </div>
158
+
159
+ {/* Error alert */}
160
+ {error && (
161
+ <div className="pb-4">
162
+ <div className="flex items-start gap-3 rounded-xl border border-red-200 bg-red-50 p-4 text-sm text-red-900" role="alert">
163
+ <span className="mt-0.5 shrink-0">
164
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round"><path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3Z" /><path d="M12 9v4" /><path d="M12 17h.01" /></svg>
165
+ </span>
166
+ <span className="flex-1">{error}</span>
167
+ <button type="button" onClick={() => setError("")} className="shrink-0 cursor-pointer border-none bg-transparent p-0 opacity-50 transition-opacity hover:opacity-100" aria-label="Dismiss">
168
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round"><path d="M18 6 6 18" /><path d="m6 6 12 12" /></svg>
169
+ </button>
170
+ </div>
171
+ </div>
172
+ )}
173
+
174
+ {/* Search */}
175
+ {hasContent && (
176
+ <div className="pb-8">
177
+ <div className="relative">
178
+ <span className="pointer-events-none absolute left-3.5 top-1/2 -translate-y-1/2 text-slate-500">{svgSearch}</span>
179
+ <input
180
+ type="text"
181
+ className="w-full rounded-xl border border-slate-200 bg-white py-3 pl-11 pr-4 text-sm text-slate-900 shadow-sm transition-all duration-150 placeholder:text-slate-500/60 focus:border-blue-600 focus:outline-none focus:ring-3 focus:ring-blue-600/10"
182
+ placeholder="Search articles and FAQs..."
183
+ value={searchQuery}
184
+ onChange={(e) => setSearchQuery(e.target.value)}
185
+ />
186
+ </div>
187
+ </div>
188
+ )}
189
+
190
+ {/* Articles + FAQs grid */}
191
+ {hasResults && (
192
+ <div className="grid grid-cols-1 gap-10 pb-16 md:grid-cols-2 md:gap-12">
193
+ {filteredArticles.length > 0 && (
194
+ <div>
195
+ <div className="mb-4 flex items-center gap-2">
196
+ <span className="text-blue-600">{svgArticle}</span>
197
+ <h2 className="m-0 text-base font-bold tracking-tight text-slate-900">Popular Articles</h2>
198
+ </div>
199
+ <div className="space-y-1">
200
+ {filteredArticles.map((article) => (
201
+ <button
202
+ key={article.id}
203
+ type="button"
204
+ className="group flex w-full cursor-pointer items-start gap-3 rounded-xl border border-transparent bg-transparent p-3 text-left transition-all duration-150 hover:border-slate-200 hover:bg-white hover:shadow-sm"
205
+ onClick={() => setSelectedArticle(article)}
206
+ >
207
+ <div className="flex h-9 w-9 min-w-9 items-center justify-center rounded-lg bg-blue-50 text-blue-600">{svgArticleSmall}</div>
208
+ <div className="min-w-0 flex-1">
209
+ <h3 className="m-0 text-sm font-semibold leading-snug text-slate-900 transition-colors group-hover:text-blue-600">{article.title}</h3>
210
+ {article.content && (
211
+ <p className="mt-0.5 line-clamp-2 text-xs leading-normal text-slate-500">
212
+ {stripHtml(article.content).substring(0, 150)}
213
+ </p>
214
+ )}
215
+ </div>
216
+ </button>
217
+ ))}
218
+ </div>
219
+ </div>
220
+ )}
221
+
222
+ {filteredFaqs.length > 0 && (
223
+ <div>
224
+ <div className="mb-4 flex items-center gap-2">
225
+ <span className="text-blue-600">{svgHelpCircle}</span>
226
+ <h2 className="m-0 text-base font-bold tracking-tight text-slate-900">Frequently Asked Questions</h2>
227
+ </div>
228
+ <div className="space-y-2">
229
+ {filteredFaqs.map((faq) => (
230
+ <FaqItem key={faq.id} question={faq.question} answer={faq.answer} />
231
+ ))}
232
+ </div>
233
+ </div>
234
+ )}
235
+ </div>
236
+ )}
237
+
238
+ {/* No results */}
239
+ {searchQuery.trim() && !hasResults && (
240
+ <div className="pb-16 text-center">
241
+ <p className="text-sm text-slate-500">No results found for &ldquo;{searchQuery}&rdquo;</p>
242
+ </div>
243
+ )}
244
+
245
+ {/* Empty state */}
246
+ {!hasContent && !error && (
247
+ <div className="pb-16 pt-4 text-center">
248
+ <div className="mx-auto max-w-sm rounded-2xl border border-slate-200 bg-white p-8 shadow-sm">
249
+ <div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-blue-50 text-blue-600">{svgMessageCircle22}</div>
250
+ <p className="m-0 text-sm font-medium text-slate-900">Need help? Our team is here for you.</p>
251
+ <button
252
+ type="button"
253
+ className="mt-5 inline-flex items-center gap-2 !rounded-full border-none bg-blue-600 !px-7 !py-3 text-sm font-semibold text-white cursor-pointer transition-all hover:bg-blue-700 hover:-translate-y-px hover:shadow-lg active:translate-y-0"
254
+ onClick={() => setDialogOpen(true)}
255
+ >
256
+ Contact Support
257
+ </button>
258
+ </div>
259
+ </div>
260
+ )}
261
+
262
+ <TicketDialog open={dialogOpen} onClose={() => setDialogOpen(false)} templates={templates} />
263
+ </div>
264
+ );
265
+ }
@@ -1,8 +1,8 @@
1
1
  "use client";
2
2
 
3
3
  import { type FormEvent, useMemo, useState } from "react";
4
- import type { Template, FormValues } from "@/lib/vicket";
5
- import { createTicket, initialFormValues, cn } from "@/lib/vicket";
4
+ import type { Template, FormValues } from "vicket";
5
+ import { createTicket, initialFormValues, cn } from "vicket";
6
6
 
7
7
  /* ── Custom hook ─────────────────────────────────── */
8
8
 
@@ -0,0 +1,169 @@
1
+ <script setup lang="ts">
2
+ import { sendReply } from "vicket";
3
+
4
+ /* ── Props / Emits ──────────────────────────────── */
5
+ const props = defineProps<{
6
+ token: string;
7
+ }>();
8
+
9
+ const emit = defineEmits<{
10
+ replied: [];
11
+ }>();
12
+
13
+ /* ── Reactive state ─────────────────────────────── */
14
+ const content = ref("");
15
+ const files = ref<File[]>([]);
16
+ const isSending = ref(false);
17
+ const error = ref("");
18
+ const success = ref("");
19
+
20
+ /* ── Helpers ────────────────────────────────────── */
21
+ const removeFile = (index: number) => {
22
+ files.value = files.value.filter((_, i) => i !== index);
23
+ };
24
+
25
+ const onFileChange = (event: Event) => {
26
+ const input = event.target as HTMLInputElement;
27
+ const ALLOWED_TYPES = ["image/png", "image/jpeg", "image/gif", "image/webp"];
28
+ const newFiles = Array.from(input.files || []).filter((f) => ALLOWED_TYPES.includes(f.type));
29
+ files.value = [...files.value, ...newFiles];
30
+ input.value = "";
31
+ };
32
+
33
+ const submitReply = async () => {
34
+ error.value = "";
35
+ success.value = "";
36
+ if (!content.value.trim() && files.value.length === 0) {
37
+ error.value = "Reply content is required.";
38
+ return;
39
+ }
40
+ if (!props.token.trim()) {
41
+ error.value = "Missing ticket token.";
42
+ return;
43
+ }
44
+ isSending.value = true;
45
+ try {
46
+ await sendReply(props.token, content.value.trim(), files.value);
47
+ content.value = "";
48
+ files.value = [];
49
+ success.value = "Reply sent.";
50
+ emit("replied");
51
+ } catch (replyError) {
52
+ error.value = replyError instanceof Error ? replyError.message : "Unexpected error.";
53
+ } finally {
54
+ isSending.value = false;
55
+ }
56
+ };
57
+ </script>
58
+
59
+ <template>
60
+ <!-- Error alert -->
61
+ <div v-if="error" class="px-5 pt-5">
62
+ <div
63
+ class="flex items-start gap-3 rounded-xl border border-red-200 bg-red-50 p-4 text-sm text-red-900"
64
+ role="alert"
65
+ >
66
+ <span class="mt-0.5 shrink-0">
67
+ <!-- IconAlert -->
68
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3Z" /><path d="M12 9v4" /><path d="M12 17h.01" /></svg>
69
+ </span>
70
+ <span class="flex-1">{{ error }}</span>
71
+ <button
72
+ type="button"
73
+ class="shrink-0 cursor-pointer border-none bg-transparent p-0 opacity-50 transition-opacity hover:opacity-100"
74
+ aria-label="Dismiss"
75
+ @click="error = ''"
76
+ >
77
+ <!-- IconX -->
78
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M18 6 6 18" /><path d="m6 6 12 12" /></svg>
79
+ </button>
80
+ </div>
81
+ </div>
82
+
83
+ <!-- Success alert -->
84
+ <div v-if="success" class="px-5 pt-5">
85
+ <div
86
+ class="flex items-start gap-3 rounded-xl border border-green-200 bg-green-50 p-4 text-sm text-green-900"
87
+ role="alert"
88
+ >
89
+ <span class="mt-0.5 shrink-0">
90
+ <!-- IconCheck -->
91
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5" /></svg>
92
+ </span>
93
+ <span class="flex-1">{{ success }}</span>
94
+ <button
95
+ type="button"
96
+ class="shrink-0 cursor-pointer border-none bg-transparent p-0 opacity-50 transition-opacity hover:opacity-100"
97
+ aria-label="Dismiss"
98
+ @click="success = ''"
99
+ >
100
+ <!-- IconX -->
101
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M18 6 6 18" /><path d="m6 6 12 12" /></svg>
102
+ </button>
103
+ </div>
104
+ </div>
105
+
106
+ <!-- Reply form -->
107
+ <div class="border-b border-slate-200 p-5">
108
+ <form class="space-y-3" @submit.prevent="submitReply">
109
+ <textarea
110
+ v-model="content"
111
+ class="min-h-[80px] w-full resize-y rounded-lg border border-slate-200 bg-slate-50 px-3 py-2.5 text-sm text-slate-900 transition-all duration-150 placeholder:text-slate-500/60 focus:border-blue-600 focus:outline-none focus:ring-3 focus:ring-blue-600/10"
112
+ placeholder="Write your reply..."
113
+ />
114
+ <div class="flex flex-wrap items-center justify-between gap-3">
115
+ <div class="flex flex-wrap items-center gap-2">
116
+ <!-- File upload -->
117
+ <label class="inline-flex cursor-pointer items-center gap-1.5 rounded-lg border border-slate-200 bg-slate-50 px-3 py-1.5 text-xs font-medium text-slate-500 transition-colors hover:border-slate-300 hover:text-slate-900">
118
+ <!-- IconPaperclip -->
119
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="m21.44 11.05-9.19 9.19a6 6 0 0 1-8.49-8.49l8.57-8.57A4 4 0 1 1 18 8.84l-8.59 8.57a2 2 0 0 1-2.83-2.83l8.49-8.48" /></svg>
120
+ Browse files
121
+ <input
122
+ type="file"
123
+ class="hidden"
124
+ multiple
125
+ accept="image/jpeg,image/png,image/gif,image/webp"
126
+ @change="onFileChange"
127
+ />
128
+ </label>
129
+ <!-- File chips -->
130
+ <span
131
+ v-for="(file, i) in files"
132
+ :key="`${file.name}-${i}`"
133
+ class="inline-flex items-center gap-1.5 rounded-full border border-slate-200 bg-slate-50 px-2.5 py-1 text-xs text-slate-500"
134
+ >
135
+ <!-- IconPaperclip -->
136
+ <svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="m21.44 11.05-9.19 9.19a6 6 0 0 1-8.49-8.49l8.57-8.57A4 4 0 1 1 18 8.84l-8.59 8.57a2 2 0 0 1-2.83-2.83l8.49-8.48" /></svg>
137
+ <span class="max-w-[120px] truncate">{{ file.name }}</span>
138
+ <button
139
+ type="button"
140
+ class="flex cursor-pointer items-center border-none bg-transparent p-0 text-slate-500 transition-colors hover:text-red-600"
141
+ :aria-label="`Remove ${file.name}`"
142
+ @click="removeFile(i)"
143
+ >
144
+ <!-- IconX -->
145
+ <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M18 6 6 18" /><path d="m6 6 12 12" /></svg>
146
+ </button>
147
+ </span>
148
+ </div>
149
+ <!-- Send button -->
150
+ <button
151
+ class="inline-flex items-center gap-2 rounded-lg border-none bg-blue-600 px-5 py-2.5 text-sm font-semibold text-white cursor-pointer transition-all hover:bg-blue-700 hover:-translate-y-px hover:shadow-lg active:translate-y-0 disabled:opacity-60 disabled:cursor-not-allowed"
152
+ type="submit"
153
+ :disabled="isSending"
154
+ >
155
+ <template v-if="isSending">
156
+ <!-- IconSpinner -->
157
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" class="animate-spin"><path d="M21 12a9 9 0 1 1-6.219-8.56" /></svg>
158
+ Sending...
159
+ </template>
160
+ <template v-else>
161
+ <!-- IconSend -->
162
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M14.536 21.686a.5.5 0 0 0 .937-.024l6.5-19a.496.496 0 0 0-.635-.635l-19 6.5a.5.5 0 0 0-.024.937l7.93 3.18a2 2 0 0 1 1.112 1.11z" /><path d="m21.854 2.147-10.94 10.939" /></svg>
163
+ Send
164
+ </template>
165
+ </button>
166
+ </div>
167
+ </form>
168
+ </div>
169
+ </template>