spaps-issue-reporting-react 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.
package/dist/index.mjs ADDED
@@ -0,0 +1,894 @@
1
+ // src/components.tsx
2
+ import * as Dialog from "@radix-ui/react-dialog";
3
+ import * as Popover from "@radix-ui/react-popover";
4
+ import {
5
+ BugBeetle,
6
+ CheckCircle,
7
+ Circle,
8
+ Spinner,
9
+ X
10
+ } from "@phosphor-icons/react";
11
+ import { formatDistanceToNow } from "date-fns";
12
+ import { useEffect, useMemo as useMemo2, useState as useState2 } from "react";
13
+
14
+ // src/provider.tsx
15
+ import {
16
+ createContext as createContext2,
17
+ useCallback,
18
+ useContext as useContext2,
19
+ useMemo,
20
+ useState
21
+ } from "react";
22
+ import {
23
+ useMutation,
24
+ useQuery,
25
+ useQueryClient
26
+ } from "@tanstack/react-query";
27
+
28
+ // src/report-mode.ts
29
+ import { createContext, useContext } from "react";
30
+ var ReportModeContext = createContext(
31
+ null
32
+ );
33
+ function useReportMode() {
34
+ return useContext(ReportModeContext);
35
+ }
36
+
37
+ // src/provider.tsx
38
+ import { jsx } from "react/jsx-runtime";
39
+ var LIST_LIMIT = 200;
40
+ var NOTE_MIN_LENGTH = 10;
41
+ var NOTE_MAX_LENGTH = 2e3;
42
+ var initialModalState = {
43
+ isOpen: false,
44
+ mode: "create",
45
+ issueReportId: null,
46
+ issue: null,
47
+ target: null,
48
+ error: null,
49
+ isHydrating: false
50
+ };
51
+ var IssueReportingContext = createContext2(
52
+ void 0
53
+ );
54
+ var defaultIssueReportingCopy = {
55
+ entryAriaLabel: "Report issue",
56
+ popoverTitle: "Issue Reports",
57
+ reportNewAction: "Report New Issue",
58
+ filtersAll: "All",
59
+ filtersOpen: "Open",
60
+ filtersClosed: "Closed",
61
+ historyHelpText: "We read every report. If something is broken or could work better, report it here and it goes straight to the people building the app.",
62
+ historyLoading: "Loading issues...",
63
+ historyLoadFailed: "Failed to load issues",
64
+ emptyAll: "No issues reported yet",
65
+ emptyOpen: "No open issues",
66
+ emptyClosed: "No resolved or ignored issues",
67
+ reportModeTitle: "Report mode is active",
68
+ reportModeDescription: "Click a highlighted section to capture the broken surface.",
69
+ reportModeCancelAction: "Cancel",
70
+ createTitlePrefix: "Report Issue",
71
+ editTitlePrefix: "Edit Issue",
72
+ replyTitlePrefix: "Reply to",
73
+ createDescriptionPrefix: "Reporting issue for page:",
74
+ editDescription: "Update the issue description below.",
75
+ replyDescription: "The original issue was marked resolved or ignored. Describe why it still needs attention.",
76
+ notePlaceholder: "Describe the issue in detail (min 10 characters)...",
77
+ noteMinimumSuffix: "more characters needed",
78
+ keyboardShortcutHint: "Cmd/Ctrl + Enter to submit",
79
+ originalIssueLabel: "Original issue",
80
+ cancelAction: "Cancel",
81
+ submitAction: "Submit",
82
+ submittingAction: "Submitting...",
83
+ editAction: "Edit",
84
+ replyAction: "Reply",
85
+ hydrateLoading: "Loading the latest issue details...",
86
+ hydrateFailed: "Failed to load the latest issue details.",
87
+ retryAction: "Retry"
88
+ };
89
+ var issueReportingKeys = {
90
+ all: ["spaps-issue-reporting"],
91
+ status: () => [...issueReportingKeys.all, "status"],
92
+ history: () => [...issueReportingKeys.all, "history"],
93
+ detail: (issueReportId) => [...issueReportingKeys.all, "detail", issueReportId]
94
+ };
95
+ function resolvePageUrl(getPageUrl) {
96
+ if (getPageUrl) {
97
+ return getPageUrl();
98
+ }
99
+ if (typeof window === "undefined") {
100
+ return "/";
101
+ }
102
+ return `${window.location.pathname}${window.location.search}${window.location.hash}`;
103
+ }
104
+ function normalizeTarget(target, getPageUrl) {
105
+ if (typeof target === "string") {
106
+ const normalized = target.trim();
107
+ return {
108
+ component_key: normalized,
109
+ component_label: normalized,
110
+ page_url: resolvePageUrl(getPageUrl),
111
+ surface_ref: null,
112
+ metadata: {}
113
+ };
114
+ }
115
+ const key = target.componentKey.trim();
116
+ const label = (target.componentLabel ?? key).trim();
117
+ return {
118
+ component_key: key,
119
+ component_label: label,
120
+ page_url: resolvePageUrl(getPageUrl),
121
+ surface_ref: target.surfaceRef ?? null,
122
+ metadata: target.metadata ?? {}
123
+ };
124
+ }
125
+ function normalizeIssueTarget(issue) {
126
+ return {
127
+ component_key: issue.target.component_key,
128
+ component_label: issue.target.component_label,
129
+ page_url: issue.target.page_url,
130
+ surface_ref: issue.target.surface_ref ?? null,
131
+ metadata: issue.target.metadata ?? {}
132
+ };
133
+ }
134
+ function toErrorMessage(error, fallback) {
135
+ if (error instanceof Error && error.message.trim()) {
136
+ return error.message;
137
+ }
138
+ return fallback;
139
+ }
140
+ function isOpenIssueStatus(status) {
141
+ return status === "open" || status === "in_progress";
142
+ }
143
+ function isClosedIssueStatus(status) {
144
+ return status === "resolved" || status === "ignored";
145
+ }
146
+ function filterIssueReports(issues, filter) {
147
+ if (filter === "open") {
148
+ return issues.filter((issue) => isOpenIssueStatus(issue.status));
149
+ }
150
+ if (filter === "closed") {
151
+ return issues.filter((issue) => isClosedIssueStatus(issue.status));
152
+ }
153
+ return issues;
154
+ }
155
+ function getIssueStatusBadgeLabel(status) {
156
+ switch (status) {
157
+ case "in_progress":
158
+ return "In Progress";
159
+ case "resolved":
160
+ return "Resolved";
161
+ case "ignored":
162
+ return "Ignored";
163
+ default:
164
+ return "Open";
165
+ }
166
+ }
167
+ function getIssueStatusClassName(status) {
168
+ switch (status) {
169
+ case "in_progress":
170
+ return "bg-amber-100 text-amber-700";
171
+ case "resolved":
172
+ return "bg-emerald-100 text-emerald-700";
173
+ case "ignored":
174
+ return "bg-slate-200 text-slate-700";
175
+ default:
176
+ return "bg-rose-100 text-rose-700";
177
+ }
178
+ }
179
+ function getEntryPointState(status) {
180
+ if (!status) {
181
+ return "neutral";
182
+ }
183
+ if (status.has_open) {
184
+ return "open";
185
+ }
186
+ if (status.has_recent_resolved) {
187
+ return "recent_resolved";
188
+ }
189
+ return "neutral";
190
+ }
191
+ function getEntryPointClassName(state) {
192
+ if (state === "open") {
193
+ return "text-rose-600";
194
+ }
195
+ if (state === "recent_resolved") {
196
+ return "text-emerald-600";
197
+ }
198
+ return "text-slate-500";
199
+ }
200
+ function getIssueNoteLengthMessage(note, copy) {
201
+ if (note.length < NOTE_MIN_LENGTH) {
202
+ return `${NOTE_MIN_LENGTH - note.length} ${copy.noteMinimumSuffix}`;
203
+ }
204
+ return `${note.length}/${NOTE_MAX_LENGTH}`;
205
+ }
206
+ function useIssueReporting() {
207
+ const context = useContext2(IssueReportingContext);
208
+ if (!context) {
209
+ throw new Error(
210
+ "useIssueReporting must be used within an IssueReportingProvider"
211
+ );
212
+ }
213
+ return context;
214
+ }
215
+ function useIssueReportingStatus() {
216
+ const { client, isEligible } = useIssueReporting();
217
+ return useQuery({
218
+ queryKey: issueReportingKeys.status(),
219
+ queryFn: () => client.issueReporting.getStatus(),
220
+ enabled: isEligible,
221
+ retry: false,
222
+ staleTime: 3e4
223
+ });
224
+ }
225
+ function useIssueReportingHistory(filter = "all") {
226
+ const { client, isEligible } = useIssueReporting();
227
+ const query = useQuery({
228
+ queryKey: issueReportingKeys.history(),
229
+ queryFn: () => client.issueReporting.list({ limit: LIST_LIMIT, offset: 0 }),
230
+ enabled: isEligible,
231
+ retry: false
232
+ });
233
+ const items = useMemo(
234
+ () => filterIssueReports(query.data?.items ?? [], filter),
235
+ [filter, query.data?.items]
236
+ );
237
+ return {
238
+ ...query,
239
+ items,
240
+ total: items.length
241
+ };
242
+ }
243
+ function useIssueReportingMutations() {
244
+ const queryClient = useQueryClient();
245
+ const { client } = useIssueReporting();
246
+ const invalidateAll = useCallback(async () => {
247
+ await queryClient.invalidateQueries({
248
+ queryKey: issueReportingKeys.all
249
+ });
250
+ }, [queryClient]);
251
+ const createMutation = useMutation({
252
+ mutationFn: (payload) => client.issueReporting.create(payload),
253
+ onSuccess: invalidateAll
254
+ });
255
+ const updateMutation = useMutation({
256
+ mutationFn: ({
257
+ issueReportId,
258
+ note
259
+ }) => client.issueReporting.update(issueReportId, { note }),
260
+ onSuccess: invalidateAll
261
+ });
262
+ const replyMutation = useMutation({
263
+ mutationFn: ({
264
+ issueReportId,
265
+ note,
266
+ reporterRoleHint
267
+ }) => client.issueReporting.reply(issueReportId, {
268
+ note,
269
+ reporter_role_hint: reporterRoleHint
270
+ }),
271
+ onSuccess: invalidateAll
272
+ });
273
+ return {
274
+ createMutation,
275
+ updateMutation,
276
+ replyMutation
277
+ };
278
+ }
279
+ function IssueReportingProvider({
280
+ client,
281
+ isEligible,
282
+ reporterRoleHint,
283
+ getPageUrl,
284
+ copy,
285
+ children
286
+ }) {
287
+ const mergedCopy = useMemo(
288
+ () => ({
289
+ ...defaultIssueReportingCopy,
290
+ ...copy
291
+ }),
292
+ [copy]
293
+ );
294
+ const queryClient = useQueryClient();
295
+ const [isReportMode, setIsReportMode] = useState(false);
296
+ const [isPopoverOpen, setIsPopoverOpen] = useState(false);
297
+ const [modalState, setModalState] = useState(initialModalState);
298
+ const closePopover = useCallback(() => {
299
+ setIsPopoverOpen(false);
300
+ }, []);
301
+ const openPopover = useCallback(() => {
302
+ setIsPopoverOpen(true);
303
+ }, []);
304
+ const enterReportMode = useCallback(() => {
305
+ setIsReportMode(true);
306
+ setIsPopoverOpen(false);
307
+ }, []);
308
+ const cancelReportMode = useCallback(() => {
309
+ setIsReportMode(false);
310
+ }, []);
311
+ const closeModal = useCallback(() => {
312
+ setModalState(initialModalState);
313
+ setIsPopoverOpen(true);
314
+ }, []);
315
+ const hydrateIssueIntoModal = useCallback(
316
+ async (issueReportId, mode) => {
317
+ setModalState({
318
+ isOpen: true,
319
+ mode,
320
+ issueReportId,
321
+ issue: null,
322
+ target: null,
323
+ error: null,
324
+ isHydrating: true
325
+ });
326
+ try {
327
+ const issue = await queryClient.fetchQuery({
328
+ queryKey: issueReportingKeys.detail(issueReportId),
329
+ queryFn: () => client.issueReporting.get(issueReportId)
330
+ });
331
+ setModalState({
332
+ isOpen: true,
333
+ mode,
334
+ issueReportId,
335
+ issue,
336
+ target: normalizeIssueTarget(issue),
337
+ error: null,
338
+ isHydrating: false
339
+ });
340
+ } catch (error) {
341
+ setModalState({
342
+ isOpen: true,
343
+ mode,
344
+ issueReportId,
345
+ issue: null,
346
+ target: null,
347
+ error: toErrorMessage(error, mergedCopy.hydrateFailed),
348
+ isHydrating: false
349
+ });
350
+ }
351
+ },
352
+ [client.issueReporting, mergedCopy.hydrateFailed, queryClient]
353
+ );
354
+ const retryModalHydration = useCallback(async () => {
355
+ if (!modalState.issueReportId || modalState.mode === "create") {
356
+ return;
357
+ }
358
+ await hydrateIssueIntoModal(modalState.issueReportId, modalState.mode);
359
+ }, [hydrateIssueIntoModal, modalState.issueReportId, modalState.mode]);
360
+ const openExistingIssueModal = useCallback(
361
+ async (issueReportId, mode) => {
362
+ setIsPopoverOpen(false);
363
+ await hydrateIssueIntoModal(issueReportId, mode);
364
+ },
365
+ [hydrateIssueIntoModal]
366
+ );
367
+ const selectPanel = useCallback(
368
+ (target) => {
369
+ if (!isEligible) {
370
+ return;
371
+ }
372
+ const normalizedTarget = normalizeTarget(target, getPageUrl);
373
+ setIsReportMode(false);
374
+ setModalState({
375
+ isOpen: true,
376
+ mode: "create",
377
+ issueReportId: null,
378
+ issue: null,
379
+ target: normalizedTarget,
380
+ error: null,
381
+ isHydrating: false
382
+ });
383
+ },
384
+ [getPageUrl, isEligible]
385
+ );
386
+ const contextValue = useMemo(
387
+ () => ({
388
+ client,
389
+ isEligible,
390
+ reporterRoleHint,
391
+ copy: mergedCopy,
392
+ isReportMode,
393
+ enterReportMode,
394
+ cancelReportMode,
395
+ selectPanel,
396
+ isPopoverOpen,
397
+ openPopover,
398
+ closePopover,
399
+ modalState,
400
+ closeModal,
401
+ openExistingIssueModal,
402
+ retryModalHydration
403
+ }),
404
+ [
405
+ cancelReportMode,
406
+ client,
407
+ closeModal,
408
+ closePopover,
409
+ enterReportMode,
410
+ isEligible,
411
+ isPopoverOpen,
412
+ isReportMode,
413
+ mergedCopy,
414
+ modalState,
415
+ openExistingIssueModal,
416
+ openPopover,
417
+ reporterRoleHint,
418
+ retryModalHydration,
419
+ selectPanel
420
+ ]
421
+ );
422
+ const reportModeValue = useMemo(
423
+ () => ({
424
+ isReportMode,
425
+ selectPanel,
426
+ cancelReportMode
427
+ }),
428
+ [cancelReportMode, isReportMode, selectPanel]
429
+ );
430
+ return /* @__PURE__ */ jsx(ReportModeContext.Provider, { value: reportModeValue, children: /* @__PURE__ */ jsx(IssueReportingContext.Provider, { value: contextValue, children }) });
431
+ }
432
+
433
+ // src/components.tsx
434
+ import { Fragment, jsx as jsx2, jsxs } from "react/jsx-runtime";
435
+ function cn(...values) {
436
+ return values.filter(Boolean).join(" ");
437
+ }
438
+ function truncate(value, max = 80) {
439
+ if (value.length <= max) {
440
+ return value;
441
+ }
442
+ return `${value.slice(0, max - 3)}...`;
443
+ }
444
+ function formatRelativeTime(timestamp) {
445
+ return formatDistanceToNow(new Date(timestamp), {
446
+ addSuffix: true
447
+ });
448
+ }
449
+ function IssueReportModeBanner() {
450
+ const { copy } = useIssueReporting();
451
+ const reportMode = useReportMode();
452
+ if (!reportMode?.isReportMode) {
453
+ return null;
454
+ }
455
+ return /* @__PURE__ */ jsx2("div", { className: "fixed inset-x-4 top-4 z-[70] flex justify-center", children: /* @__PURE__ */ jsx2("div", { className: "max-w-xl rounded-full border border-amber-300 bg-amber-50/95 px-4 py-3 text-sm text-amber-950 shadow-lg backdrop-blur", children: /* @__PURE__ */ jsxs("div", { className: "flex flex-wrap items-center justify-center gap-3", children: [
456
+ /* @__PURE__ */ jsx2("div", { className: "font-medium", children: copy.reportModeTitle }),
457
+ /* @__PURE__ */ jsx2("div", { className: "text-amber-900/80", children: copy.reportModeDescription }),
458
+ /* @__PURE__ */ jsx2(
459
+ "button",
460
+ {
461
+ type: "button",
462
+ className: "rounded-full border border-amber-400 px-3 py-1 font-medium text-amber-900 transition hover:bg-amber-100",
463
+ onClick: reportMode.cancelReportMode,
464
+ children: copy.reportModeCancelAction
465
+ }
466
+ )
467
+ ] }) }) });
468
+ }
469
+ function IssueList({
470
+ filter,
471
+ onEdit,
472
+ onReply
473
+ }) {
474
+ const { copy } = useIssueReporting();
475
+ const history = useIssueReportingHistory(filter);
476
+ if (history.isPending) {
477
+ return /* @__PURE__ */ jsxs("div", { className: "flex items-center gap-2 rounded-2xl border border-slate-200 bg-slate-50 px-4 py-5 text-sm text-slate-600", children: [
478
+ /* @__PURE__ */ jsx2(Spinner, { className: "h-4 w-4 animate-spin" }),
479
+ /* @__PURE__ */ jsx2("span", { children: copy.historyLoading })
480
+ ] });
481
+ }
482
+ if (history.error) {
483
+ return /* @__PURE__ */ jsxs("div", { className: "space-y-3 rounded-2xl border border-rose-200 bg-rose-50 px-4 py-4 text-sm text-rose-700", children: [
484
+ /* @__PURE__ */ jsx2("div", { children: history.error.message || copy.historyLoadFailed }),
485
+ /* @__PURE__ */ jsx2(
486
+ "button",
487
+ {
488
+ type: "button",
489
+ className: "rounded-full border border-rose-300 px-3 py-1 font-medium transition hover:bg-rose-100",
490
+ onClick: () => history.refetch(),
491
+ children: copy.retryAction
492
+ }
493
+ )
494
+ ] });
495
+ }
496
+ if (history.items.length === 0) {
497
+ const emptyMessage = filter === "open" ? copy.emptyOpen : filter === "closed" ? copy.emptyClosed : copy.emptyAll;
498
+ return /* @__PURE__ */ jsx2("div", { className: "rounded-2xl border border-dashed border-slate-200 bg-slate-50 px-4 py-8 text-center text-sm text-slate-500", children: emptyMessage });
499
+ }
500
+ return /* @__PURE__ */ jsx2("div", { className: "max-h-80 space-y-2 overflow-y-auto pr-1", children: history.items.map((issue) => {
501
+ const isClosed = isClosedIssueStatus(issue.status);
502
+ const actionLabel = isClosed ? copy.replyAction : copy.editAction;
503
+ const onAction = () => isClosed ? onReply(issue.id) : onEdit(issue.id);
504
+ return /* @__PURE__ */ jsx2(
505
+ "div",
506
+ {
507
+ className: "rounded-2xl border border-slate-200 bg-white px-3 py-3 shadow-sm",
508
+ children: /* @__PURE__ */ jsxs("div", { className: "flex items-start gap-3", children: [
509
+ /* @__PURE__ */ jsx2("div", { className: "mt-0.5 flex-shrink-0", children: isClosed ? /* @__PURE__ */ jsx2(CheckCircle, { className: "h-4 w-4 text-emerald-600", weight: "fill" }) : /* @__PURE__ */ jsx2(Circle, { className: "h-4 w-4 text-rose-600", weight: "fill" }) }),
510
+ /* @__PURE__ */ jsxs("div", { className: "min-w-0 flex-1", children: [
511
+ /* @__PURE__ */ jsxs("div", { className: "flex items-start justify-between gap-2", children: [
512
+ /* @__PURE__ */ jsxs("div", { className: "min-w-0", children: [
513
+ /* @__PURE__ */ jsx2("div", { className: "truncate text-sm font-semibold text-slate-900", children: issue.target.component_label }),
514
+ /* @__PURE__ */ jsxs("div", { className: "mt-1 flex flex-wrap items-center gap-2", children: [
515
+ /* @__PURE__ */ jsx2(
516
+ "span",
517
+ {
518
+ className: cn(
519
+ "rounded-full px-2 py-0.5 text-[11px] font-medium",
520
+ getIssueStatusClassName(issue.status)
521
+ ),
522
+ children: getIssueStatusBadgeLabel(issue.status)
523
+ }
524
+ ),
525
+ /* @__PURE__ */ jsx2("span", { className: "text-xs text-slate-400", children: formatRelativeTime(issue.updated_at) })
526
+ ] })
527
+ ] }),
528
+ /* @__PURE__ */ jsx2(
529
+ "button",
530
+ {
531
+ type: "button",
532
+ className: "rounded-full border border-slate-200 px-3 py-1 text-xs font-medium text-slate-700 transition hover:bg-slate-50",
533
+ onClick: onAction,
534
+ children: actionLabel
535
+ }
536
+ )
537
+ ] }),
538
+ /* @__PURE__ */ jsx2("p", { className: "mt-2 text-sm text-slate-600", children: truncate(issue.note) }),
539
+ /* @__PURE__ */ jsx2("div", { className: "mt-2 truncate text-xs text-slate-400", children: issue.target.page_url })
540
+ ] })
541
+ ] })
542
+ },
543
+ issue.id
544
+ );
545
+ }) });
546
+ }
547
+ function IssueReportPopover({ children }) {
548
+ const [filter, setFilter] = useState2("all");
549
+ const {
550
+ copy,
551
+ isPopoverOpen,
552
+ openPopover,
553
+ closePopover,
554
+ enterReportMode,
555
+ openExistingIssueModal
556
+ } = useIssueReporting();
557
+ const history = useIssueReportingHistory("all");
558
+ const status = useIssueReportingStatus();
559
+ const allItems = history.data?.items ?? [];
560
+ const counts = useMemo2(
561
+ () => ({
562
+ all: allItems.length,
563
+ open: filterIssueReports(allItems, "open").length,
564
+ closed: filterIssueReports(allItems, "closed").length
565
+ }),
566
+ [allItems]
567
+ );
568
+ return /* @__PURE__ */ jsxs(
569
+ Popover.Root,
570
+ {
571
+ open: isPopoverOpen,
572
+ onOpenChange: (open) => open ? openPopover() : closePopover(),
573
+ children: [
574
+ /* @__PURE__ */ jsx2(Popover.Trigger, { asChild: true, children }),
575
+ /* @__PURE__ */ jsx2(Popover.Portal, { children: /* @__PURE__ */ jsx2(
576
+ Popover.Content,
577
+ {
578
+ align: "end",
579
+ side: "top",
580
+ sideOffset: 12,
581
+ className: "z-[70] w-[360px] rounded-3xl border border-slate-200 bg-white p-4 shadow-[0_18px_48px_rgba(15,23,42,0.18)]",
582
+ children: /* @__PURE__ */ jsxs("div", { className: "space-y-4", children: [
583
+ /* @__PURE__ */ jsxs("div", { className: "flex items-center justify-between gap-3", children: [
584
+ /* @__PURE__ */ jsxs("div", { children: [
585
+ /* @__PURE__ */ jsx2("h3", { className: "text-sm font-semibold text-slate-900", children: copy.popoverTitle }),
586
+ status.data?.has_open ? /* @__PURE__ */ jsxs("p", { className: "mt-1 text-xs text-slate-500", children: [
587
+ status.data.open_count,
588
+ " open issue",
589
+ status.data.open_count === 1 ? "" : "s"
590
+ ] }) : null
591
+ ] }),
592
+ /* @__PURE__ */ jsx2(
593
+ "button",
594
+ {
595
+ type: "button",
596
+ className: "rounded-full bg-slate-900 px-3 py-2 text-xs font-semibold text-white transition hover:bg-slate-800",
597
+ onClick: () => {
598
+ closePopover();
599
+ enterReportMode();
600
+ },
601
+ children: copy.reportNewAction
602
+ }
603
+ )
604
+ ] }),
605
+ /* @__PURE__ */ jsx2("div", { className: "flex gap-2", children: [
606
+ ["all", copy.filtersAll, counts.all],
607
+ ["open", copy.filtersOpen, counts.open],
608
+ ["closed", copy.filtersClosed, counts.closed]
609
+ ].map(([value, label, count]) => /* @__PURE__ */ jsxs(
610
+ "button",
611
+ {
612
+ type: "button",
613
+ className: cn(
614
+ "rounded-full px-3 py-1.5 text-xs font-medium transition",
615
+ filter === value ? "bg-slate-900 text-white" : "bg-slate-100 text-slate-600 hover:bg-slate-200"
616
+ ),
617
+ onClick: () => setFilter(value),
618
+ children: [
619
+ label,
620
+ count > 0 ? ` (${count})` : ""
621
+ ]
622
+ },
623
+ value
624
+ )) }),
625
+ /* @__PURE__ */ jsx2(
626
+ IssueList,
627
+ {
628
+ filter,
629
+ onEdit: (issueReportId) => openExistingIssueModal(issueReportId, "edit"),
630
+ onReply: (issueReportId) => openExistingIssueModal(issueReportId, "reply")
631
+ }
632
+ ),
633
+ /* @__PURE__ */ jsx2("p", { className: "border-t border-slate-100 pt-3 text-xs leading-relaxed text-slate-500", children: copy.historyHelpText })
634
+ ] })
635
+ }
636
+ ) })
637
+ ]
638
+ }
639
+ );
640
+ }
641
+ function IssueReportModal() {
642
+ const {
643
+ copy,
644
+ reporterRoleHint,
645
+ modalState,
646
+ closeModal,
647
+ retryModalHydration
648
+ } = useIssueReporting();
649
+ const { createMutation, updateMutation, replyMutation } = useIssueReportingMutations();
650
+ const [note, setNote] = useState2("");
651
+ const [submitError, setSubmitError] = useState2(null);
652
+ const { isOpen, mode, issue, target, isHydrating, error } = modalState;
653
+ useEffect(() => {
654
+ if (!isOpen) {
655
+ setNote("");
656
+ setSubmitError(null);
657
+ return;
658
+ }
659
+ if (mode === "edit" && issue) {
660
+ setNote(issue.note);
661
+ } else {
662
+ setNote("");
663
+ }
664
+ setSubmitError(null);
665
+ }, [isOpen, mode, issue]);
666
+ const isValid = note.trim().length >= 10 && note.trim().length <= 2e3;
667
+ const isSubmitting = createMutation.isPending || updateMutation.isPending || replyMutation.isPending;
668
+ const title = mode === "create" ? `${copy.createTitlePrefix}: ${target?.component_label ?? ""}` : mode === "edit" ? `${copy.editTitlePrefix}: ${target?.component_label ?? ""}` : `${copy.replyTitlePrefix}: ${target?.component_label ?? ""}`;
669
+ const handleSubmit = async () => {
670
+ if (!target || !isValid || isSubmitting) {
671
+ return;
672
+ }
673
+ setSubmitError(null);
674
+ try {
675
+ const normalizedNote = note.trim();
676
+ if (mode === "create") {
677
+ await createMutation.mutateAsync({
678
+ target,
679
+ note: normalizedNote,
680
+ reporter_role_hint: reporterRoleHint
681
+ });
682
+ } else if (mode === "edit" && issue) {
683
+ await updateMutation.mutateAsync({
684
+ issueReportId: issue.id,
685
+ note: normalizedNote
686
+ });
687
+ } else if (mode === "reply" && issue) {
688
+ await replyMutation.mutateAsync({
689
+ issueReportId: issue.id,
690
+ note: normalizedNote,
691
+ reporterRoleHint
692
+ });
693
+ }
694
+ closeModal();
695
+ } catch (submissionError) {
696
+ setSubmitError(
697
+ submissionError instanceof Error ? submissionError.message : "Failed to submit issue report"
698
+ );
699
+ }
700
+ };
701
+ return /* @__PURE__ */ jsx2(Dialog.Root, { open: isOpen, onOpenChange: (open) => !open && closeModal(), children: /* @__PURE__ */ jsxs(Dialog.Portal, { children: [
702
+ /* @__PURE__ */ jsx2(Dialog.Overlay, { className: "fixed inset-0 z-[80] bg-slate-950/45 backdrop-blur-sm" }),
703
+ /* @__PURE__ */ jsxs(Dialog.Content, { className: "fixed left-1/2 top-1/2 z-[81] w-[calc(100vw-2rem)] max-w-xl -translate-x-1/2 -translate-y-1/2 rounded-[28px] border border-slate-200 bg-white p-6 shadow-[0_28px_80px_rgba(15,23,42,0.24)] focus:outline-none", children: [
704
+ /* @__PURE__ */ jsx2(Dialog.Title, { className: "text-lg font-semibold text-slate-950", children: title }),
705
+ /* @__PURE__ */ jsx2(Dialog.Description, { className: "mt-2 text-sm text-slate-600", children: mode === "create" && target ? /* @__PURE__ */ jsxs(Fragment, { children: [
706
+ copy.createDescriptionPrefix,
707
+ " ",
708
+ /* @__PURE__ */ jsx2("code", { className: "rounded bg-slate-100 px-1.5 py-0.5 text-xs text-slate-700", children: target.page_url })
709
+ ] }) : mode === "edit" ? copy.editDescription : copy.replyDescription }),
710
+ isHydrating ? /* @__PURE__ */ jsxs("div", { className: "mt-5 flex items-center gap-2 rounded-2xl border border-slate-200 bg-slate-50 px-4 py-4 text-sm text-slate-600", children: [
711
+ /* @__PURE__ */ jsx2(Spinner, { className: "h-4 w-4 animate-spin" }),
712
+ /* @__PURE__ */ jsx2("span", { children: copy.hydrateLoading })
713
+ ] }) : error ? /* @__PURE__ */ jsxs("div", { className: "mt-5 space-y-3 rounded-2xl border border-rose-200 bg-rose-50 px-4 py-4 text-sm text-rose-700", children: [
714
+ /* @__PURE__ */ jsx2("div", { children: error }),
715
+ /* @__PURE__ */ jsxs("div", { className: "flex gap-2", children: [
716
+ /* @__PURE__ */ jsx2(
717
+ "button",
718
+ {
719
+ type: "button",
720
+ className: "rounded-full border border-rose-300 px-3 py-1 font-medium transition hover:bg-rose-100",
721
+ onClick: () => retryModalHydration(),
722
+ children: copy.retryAction
723
+ }
724
+ ),
725
+ /* @__PURE__ */ jsx2(
726
+ "button",
727
+ {
728
+ type: "button",
729
+ className: "rounded-full border border-slate-300 px-3 py-1 font-medium text-slate-700 transition hover:bg-slate-50",
730
+ onClick: closeModal,
731
+ children: copy.cancelAction
732
+ }
733
+ )
734
+ ] })
735
+ ] }) : /* @__PURE__ */ jsxs(Fragment, { children: [
736
+ mode === "reply" && issue ? /* @__PURE__ */ jsxs("div", { className: "mt-5 rounded-2xl border border-slate-200 bg-slate-50 px-4 py-3", children: [
737
+ /* @__PURE__ */ jsx2("div", { className: "text-xs font-medium uppercase tracking-wide text-slate-500", children: copy.originalIssueLabel }),
738
+ /* @__PURE__ */ jsx2("p", { className: "mt-1 text-sm text-slate-700", children: issue.note })
739
+ ] }) : null,
740
+ /* @__PURE__ */ jsxs("div", { className: "mt-5 space-y-2", children: [
741
+ /* @__PURE__ */ jsx2(
742
+ "textarea",
743
+ {
744
+ value: note,
745
+ onChange: (event) => setNote(event.target.value),
746
+ onKeyDown: (event) => {
747
+ if ((event.metaKey || event.ctrlKey) && event.key === "Enter") {
748
+ event.preventDefault();
749
+ void handleSubmit();
750
+ }
751
+ },
752
+ placeholder: copy.notePlaceholder,
753
+ className: "h-36 w-full resize-none rounded-2xl border border-slate-300 px-4 py-3 text-sm text-slate-800 outline-none transition focus:border-slate-500 focus:ring-2 focus:ring-slate-200",
754
+ disabled: isSubmitting,
755
+ autoFocus: true
756
+ }
757
+ ),
758
+ /* @__PURE__ */ jsxs("div", { className: "flex items-center justify-between text-xs text-slate-500", children: [
759
+ /* @__PURE__ */ jsx2("span", { children: getIssueNoteLengthMessage(note, copy) }),
760
+ /* @__PURE__ */ jsx2("span", { children: copy.keyboardShortcutHint })
761
+ ] })
762
+ ] }),
763
+ submitError ? /* @__PURE__ */ jsx2("div", { className: "mt-4 rounded-2xl border border-rose-200 bg-rose-50 px-4 py-3 text-sm text-rose-700", children: submitError }) : null,
764
+ /* @__PURE__ */ jsxs("div", { className: "mt-6 flex justify-end gap-3", children: [
765
+ /* @__PURE__ */ jsx2(
766
+ "button",
767
+ {
768
+ type: "button",
769
+ className: "rounded-full border border-slate-300 px-4 py-2 text-sm font-medium text-slate-700 transition hover:bg-slate-50",
770
+ onClick: closeModal,
771
+ disabled: isSubmitting,
772
+ children: copy.cancelAction
773
+ }
774
+ ),
775
+ /* @__PURE__ */ jsx2(
776
+ "button",
777
+ {
778
+ type: "button",
779
+ className: cn(
780
+ "rounded-full px-4 py-2 text-sm font-semibold text-white transition",
781
+ isValid && !isSubmitting ? "bg-slate-900 hover:bg-slate-800" : "cursor-not-allowed bg-slate-300"
782
+ ),
783
+ onClick: () => void handleSubmit(),
784
+ disabled: !isValid || isSubmitting,
785
+ children: isSubmitting ? copy.submittingAction : copy.submitAction
786
+ }
787
+ )
788
+ ] })
789
+ ] }),
790
+ /* @__PURE__ */ jsx2(Dialog.Close, { asChild: true, children: /* @__PURE__ */ jsx2(
791
+ "button",
792
+ {
793
+ type: "button",
794
+ className: "absolute right-4 top-4 inline-flex h-9 w-9 items-center justify-center rounded-full border border-slate-200 text-slate-500 transition hover:bg-slate-50 hover:text-slate-900",
795
+ "aria-label": "Close issue report modal",
796
+ children: /* @__PURE__ */ jsx2(X, { className: "h-4 w-4" })
797
+ }
798
+ ) })
799
+ ] })
800
+ ] }) });
801
+ }
802
+ function FloatingIssueReportButton({
803
+ className,
804
+ positionClassName
805
+ }) {
806
+ const {
807
+ copy,
808
+ isEligible,
809
+ isReportMode,
810
+ isPopoverOpen,
811
+ openPopover,
812
+ closePopover
813
+ } = useIssueReporting();
814
+ const status = useIssueReportingStatus();
815
+ const entryPointState = getEntryPointState(status.data);
816
+ if (!isEligible) {
817
+ return null;
818
+ }
819
+ return /* @__PURE__ */ jsxs(Fragment, { children: [
820
+ /* @__PURE__ */ jsx2(IssueReportModeBanner, {}),
821
+ !isReportMode ? /* @__PURE__ */ jsx2("div", { className: cn("fixed bottom-12 right-4 z-[65]", positionClassName), children: /* @__PURE__ */ jsx2(IssueReportPopover, { children: /* @__PURE__ */ jsx2(
822
+ "button",
823
+ {
824
+ type: "button",
825
+ "aria-label": copy.entryAriaLabel,
826
+ onClick: () => isPopoverOpen ? closePopover() : openPopover(),
827
+ className: cn(
828
+ "flex h-12 w-12 items-center justify-center rounded-full border border-slate-200 bg-white shadow-lg transition hover:-translate-y-0.5 hover:shadow-xl focus:outline-none focus:ring-2 focus:ring-slate-300",
829
+ status.isPending && "animate-pulse",
830
+ className
831
+ ),
832
+ children: /* @__PURE__ */ jsx2(
833
+ BugBeetle,
834
+ {
835
+ className: cn("h-6 w-6", getEntryPointClassName(entryPointState)),
836
+ weight: "fill"
837
+ }
838
+ )
839
+ }
840
+ ) }) }) : null,
841
+ /* @__PURE__ */ jsx2(IssueReportModal, {})
842
+ ] });
843
+ }
844
+ function ReportableSection({
845
+ reportableName,
846
+ children,
847
+ className,
848
+ as: Component = "div"
849
+ }) {
850
+ const reportMode = useReportMode();
851
+ const isSelectable = Boolean(reportMode?.isReportMode);
852
+ return /* @__PURE__ */ jsx2(
853
+ Component,
854
+ {
855
+ className: cn(
856
+ className,
857
+ isSelectable && "cursor-pointer ring-2 ring-amber-400 transition hover:ring-amber-500"
858
+ ),
859
+ onClick: isSelectable ? () => {
860
+ reportMode?.selectPanel(reportableName);
861
+ } : void 0,
862
+ onKeyDown: isSelectable ? (event) => {
863
+ if (event.key === "Enter" || event.key === " ") {
864
+ event.preventDefault();
865
+ reportMode?.selectPanel(reportableName);
866
+ }
867
+ } : void 0,
868
+ role: isSelectable ? "button" : void 0,
869
+ tabIndex: isSelectable ? 0 : void 0,
870
+ children
871
+ }
872
+ );
873
+ }
874
+ export {
875
+ FloatingIssueReportButton,
876
+ IssueReportingProvider,
877
+ ReportModeContext,
878
+ ReportableSection,
879
+ defaultIssueReportingCopy,
880
+ filterIssueReports,
881
+ getEntryPointClassName,
882
+ getEntryPointState,
883
+ getIssueNoteLengthMessage,
884
+ getIssueStatusBadgeLabel,
885
+ getIssueStatusClassName,
886
+ isClosedIssueStatus,
887
+ isOpenIssueStatus,
888
+ issueReportingKeys,
889
+ useIssueReporting,
890
+ useIssueReportingHistory,
891
+ useIssueReportingMutations,
892
+ useIssueReportingStatus,
893
+ useReportMode
894
+ };