spaps-issue-reporting-react 0.4.1 → 0.4.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.
package/dist/index.mjs CHANGED
@@ -125,13 +125,31 @@ var defaultIssueReportingCopy = {
125
125
  retryAction: "Retry",
126
126
  originHumanLabel: "Human",
127
127
  originMachineLabel: "Machine",
128
- machineOriginFallback: "system"
128
+ machineOriginFallback: "system",
129
+ threadTitle: "Conversation",
130
+ threadDescription: "Messages from the support team about this report, and your replies.",
131
+ threadLoading: "Loading conversation...",
132
+ threadLoadFailed: "Failed to load the conversation",
133
+ threadEmpty: "No messages on this report yet.",
134
+ threadNeedsResponseBadge: "Needs your response",
135
+ threadAuthorOperator: "Support",
136
+ threadAuthorReporter: "You",
137
+ threadKindClarificationRequest: "Question",
138
+ threadKindReporterResponse: "Your reply",
139
+ threadKindFinalResponse: "Resolution",
140
+ threadResponsePlaceholder: "Write your response...",
141
+ threadResponseLabel: "Respond to the support team",
142
+ threadResponseSubmitAction: "Send response",
143
+ threadResponseSubmittingAction: "Sending...",
144
+ threadResponseConflict: "That response was already sent, or the message changed. Refresh and try again.",
145
+ threadResponseFailed: "Failed to send your response"
129
146
  };
130
147
  var issueReportingKeys = {
131
148
  all: ["spaps-issue-reporting"],
132
149
  status: (scope) => [...issueReportingKeys.all, "status", scope],
133
150
  history: (scope) => [...issueReportingKeys.all, "history", scope],
134
- detail: (issueReportId) => [...issueReportingKeys.all, "detail", issueReportId]
151
+ detail: (issueReportId) => [...issueReportingKeys.all, "detail", issueReportId],
152
+ messages: (issueReportId) => [...issueReportingKeys.all, "messages", issueReportId]
135
153
  };
136
154
  function resolvePageUrl(getPageUrl) {
137
155
  if (getPageUrl) {
@@ -301,6 +319,27 @@ function getEntryPointClassName(state) {
301
319
  }
302
320
  return "text-slate-500";
303
321
  }
322
+ function selectReporterVisibleMessages(messages) {
323
+ return messages.filter(
324
+ (message) => message.reporter_visible && message.state === "active"
325
+ ).slice().sort((a, b) => a.created_at.localeCompare(b.created_at));
326
+ }
327
+ function isReporterMessageConflict(error) {
328
+ if (!error || typeof error !== "object") {
329
+ return false;
330
+ }
331
+ const record = error;
332
+ const code = record.code ?? record.error?.code;
333
+ if (typeof code === "string" && code === "ISSUE_REPORT_MESSAGE_CONFLICT") {
334
+ return true;
335
+ }
336
+ const status = record.status ?? record.statusCode ?? record.status_code ?? void 0;
337
+ if (status === 409) {
338
+ return true;
339
+ }
340
+ const message = error instanceof Error ? error.message : typeof record.message === "string" ? record.message : "";
341
+ return /ISSUE_REPORT_MESSAGE_CONFLICT/i.test(message) || /\b409\b/.test(message);
342
+ }
304
343
  function getIssueNoteLengthMessage(note, copy) {
305
344
  if (note.length < NOTE_MIN_LENGTH) {
306
345
  return `${NOTE_MIN_LENGTH - note.length} ${copy.noteMinimumSuffix}`;
@@ -424,6 +463,39 @@ function useIssueReportingMutations() {
424
463
  replyMutation
425
464
  };
426
465
  }
466
+ function useIssueReportingMessages(issueReportId) {
467
+ const { client, isEligible } = useIssueReporting();
468
+ const listMessages = client.issueReporting.listMessages;
469
+ return useQuery({
470
+ queryKey: issueReportingKeys.messages(issueReportId ?? "none"),
471
+ queryFn: () => listMessages(issueReportId),
472
+ enabled: isEligible && Boolean(issueReportId) && Boolean(listMessages),
473
+ retry: false
474
+ });
475
+ }
476
+ function useIssueReportingMessageMutation(issueReportId) {
477
+ const queryClient = useQueryClient();
478
+ const { client } = useIssueReporting();
479
+ return useMutation({
480
+ mutationFn: (payload) => {
481
+ const submit = client.issueReporting.submitMessage;
482
+ if (!submit || !issueReportId) {
483
+ return Promise.reject(
484
+ new Error("This client does not support submitting messages.")
485
+ );
486
+ }
487
+ return submit(issueReportId, payload);
488
+ },
489
+ onSuccess: async () => {
490
+ if (!issueReportId) {
491
+ return;
492
+ }
493
+ await queryClient.invalidateQueries({
494
+ queryKey: issueReportingKeys.messages(issueReportId)
495
+ });
496
+ }
497
+ });
498
+ }
427
499
  function IssueReportingProvider({
428
500
  client,
429
501
  isEligible,
@@ -453,6 +525,7 @@ function IssueReportingProvider({
453
525
  const [scope, setScope] = useState(
454
526
  () => resolveInitialScope(defaultScope, allowTenantScope)
455
527
  );
528
+ const [needsResponseMap, setNeedsResponseMap] = useState({});
456
529
  const [pageConfigs, setPageConfigs] = useState([]);
457
530
  const [registeredTargets, setRegisteredTargets] = useState(
458
531
  []
@@ -477,6 +550,21 @@ function IssueReportingProvider({
477
550
  useEffect(() => {
478
551
  setScope(resolveInitialScope(defaultScope, allowTenantScope));
479
552
  }, [allowTenantScope, defaultScope]);
553
+ const setNeedsResponse = useCallback(
554
+ (issueReportId, needsResponse) => {
555
+ setNeedsResponseMap((current) => {
556
+ if (Boolean(current[issueReportId]) === needsResponse) {
557
+ return current;
558
+ }
559
+ return { ...current, [issueReportId]: needsResponse };
560
+ });
561
+ },
562
+ []
563
+ );
564
+ const needsResponseIssueIds = useMemo(
565
+ () => Object.entries(needsResponseMap).filter(([, needs]) => needs).map(([id]) => id),
566
+ [needsResponseMap]
567
+ );
480
568
  const closePopover = useCallback(() => {
481
569
  setIsPopoverOpen(false);
482
570
  }, []);
@@ -664,7 +752,9 @@ function IssueReportingProvider({
664
752
  createMode,
665
753
  inputModes: resolvedInputModes,
666
754
  defaultInputMode: resolvedDefaultInputMode,
667
- voice: resolvedVoiceConfig
755
+ voice: resolvedVoiceConfig,
756
+ needsResponseIssueIds,
757
+ setNeedsResponse
668
758
  }),
669
759
  [
670
760
  allowTenantScope,
@@ -680,6 +770,7 @@ function IssueReportingProvider({
680
770
  isReportMode,
681
771
  mergedCopy,
682
772
  modalState,
773
+ needsResponseIssueIds,
683
774
  openExistingIssueModal,
684
775
  openPageIssueModal,
685
776
  openPopover,
@@ -691,6 +782,7 @@ function IssueReportingProvider({
691
782
  resolvedVoiceConfig,
692
783
  scope,
693
784
  selectPanel,
785
+ setNeedsResponse,
694
786
  startNewIssue
695
787
  ]
696
788
  );
@@ -1109,7 +1201,8 @@ function IssueReportModalBody({
1109
1201
  children: isSubmitting ? copy.submittingAction : copy.submitAction
1110
1202
  }
1111
1203
  )
1112
- ] })
1204
+ ] }),
1205
+ mode !== "create" && issue ? /* @__PURE__ */ jsx2(IssueReportMessageThread, { issueReportId: issue.id }) : null
1113
1206
  ] });
1114
1207
  }
1115
1208
  function useIssueReportVoiceCapture({
@@ -1463,6 +1556,237 @@ function useAttachmentState(existingAttachments) {
1463
1556
  reset
1464
1557
  };
1465
1558
  }
1559
+ var REPORTER_MESSAGE_MIN_LENGTH = 1;
1560
+ var REPORTER_MESSAGE_MAX_LENGTH = 2e3;
1561
+ function generateIdempotencyKey() {
1562
+ const cryptoObj = typeof globalThis !== "undefined" ? globalThis.crypto : void 0;
1563
+ if (cryptoObj && typeof cryptoObj.randomUUID === "function") {
1564
+ return `reporter-msg-${cryptoObj.randomUUID()}`;
1565
+ }
1566
+ return `reporter-msg-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
1567
+ }
1568
+ function getMessageKindLabel(kind, copy) {
1569
+ switch (kind) {
1570
+ case "clarification_request":
1571
+ return copy.threadKindClarificationRequest;
1572
+ case "reporter_response":
1573
+ return copy.threadKindReporterResponse;
1574
+ case "final_response":
1575
+ return copy.threadKindFinalResponse;
1576
+ default:
1577
+ return kind;
1578
+ }
1579
+ }
1580
+ function getMessageKindClassName(kind) {
1581
+ switch (kind) {
1582
+ case "clarification_request":
1583
+ return "bg-amber-100 text-amber-700";
1584
+ case "final_response":
1585
+ return "bg-emerald-100 text-emerald-700";
1586
+ default:
1587
+ return "bg-slate-100 text-slate-600";
1588
+ }
1589
+ }
1590
+ function MessageBubble({
1591
+ message,
1592
+ copy
1593
+ }) {
1594
+ const isReporter = message.actor.author_type === "reporter";
1595
+ const isFinal = message.kind === "final_response";
1596
+ const authorLabel = isReporter ? copy.threadAuthorReporter : copy.threadAuthorOperator;
1597
+ return /* @__PURE__ */ jsxs(
1598
+ "li",
1599
+ {
1600
+ className: cn(
1601
+ "flex flex-col gap-1",
1602
+ isReporter ? "items-end" : "items-start"
1603
+ ),
1604
+ children: [
1605
+ /* @__PURE__ */ jsxs("div", { className: "flex items-center gap-2", children: [
1606
+ /* @__PURE__ */ jsx2(
1607
+ "span",
1608
+ {
1609
+ className: cn(
1610
+ "rounded-full px-2 py-0.5 font-medium",
1611
+ BADGE_TEXT,
1612
+ getMessageKindClassName(message.kind)
1613
+ ),
1614
+ children: getMessageKindLabel(message.kind, copy)
1615
+ }
1616
+ ),
1617
+ /* @__PURE__ */ jsx2("span", { className: cn("font-medium text-slate-600", LABEL_TEXT), children: authorLabel }),
1618
+ /* @__PURE__ */ jsx2(
1619
+ "time",
1620
+ {
1621
+ className: "text-xs text-slate-400",
1622
+ dateTime: message.created_at,
1623
+ children: formatRelativeTime(message.created_at)
1624
+ }
1625
+ )
1626
+ ] }),
1627
+ /* @__PURE__ */ jsx2(
1628
+ "div",
1629
+ {
1630
+ className: cn(
1631
+ "max-w-[85%] whitespace-pre-wrap rounded-2xl border px-3 py-2 text-sm",
1632
+ isReporter ? "border-slate-900 bg-slate-900 text-white" : isFinal ? "border-emerald-200 bg-emerald-50 text-emerald-950" : "border-slate-200 bg-white text-slate-800"
1633
+ ),
1634
+ children: message.body
1635
+ }
1636
+ )
1637
+ ]
1638
+ }
1639
+ );
1640
+ }
1641
+ function ReporterResponseComposer({
1642
+ issueReportId,
1643
+ copy
1644
+ }) {
1645
+ const mutation = useIssueReportingMessageMutation(issueReportId);
1646
+ const idempotencyKeyRef = useRef2(generateIdempotencyKey());
1647
+ const [body, setBody] = useState2("");
1648
+ const [submitError, setSubmitError] = useState2(null);
1649
+ const normalized = body.trim();
1650
+ const isValid = normalized.length >= REPORTER_MESSAGE_MIN_LENGTH && normalized.length <= REPORTER_MESSAGE_MAX_LENGTH;
1651
+ const isSubmitting = mutation.isPending;
1652
+ const handleSubmit = async () => {
1653
+ if (!isValid || isSubmitting) {
1654
+ return;
1655
+ }
1656
+ setSubmitError(null);
1657
+ try {
1658
+ await mutation.mutateAsync({
1659
+ body: normalized,
1660
+ idempotency_key: idempotencyKeyRef.current
1661
+ });
1662
+ setBody("");
1663
+ idempotencyKeyRef.current = generateIdempotencyKey();
1664
+ } catch (error) {
1665
+ if (isReporterMessageConflict(error)) {
1666
+ setSubmitError(copy.threadResponseConflict);
1667
+ idempotencyKeyRef.current = generateIdempotencyKey();
1668
+ return;
1669
+ }
1670
+ setSubmitError(resolveErrorMessage(error, copy.threadResponseFailed));
1671
+ }
1672
+ };
1673
+ return /* @__PURE__ */ jsxs("div", { className: "mt-3 space-y-2", children: [
1674
+ /* @__PURE__ */ jsx2(
1675
+ "label",
1676
+ {
1677
+ htmlFor: "issue-report-thread-response",
1678
+ className: cn(
1679
+ "block font-medium uppercase tracking-wide text-slate-500",
1680
+ LABEL_TEXT
1681
+ ),
1682
+ children: copy.threadResponseLabel
1683
+ }
1684
+ ),
1685
+ /* @__PURE__ */ jsx2(
1686
+ "textarea",
1687
+ {
1688
+ id: "issue-report-thread-response",
1689
+ value: body,
1690
+ onChange: (event) => setBody(event.target.value),
1691
+ onKeyDown: (event) => {
1692
+ if ((event.metaKey || event.ctrlKey) && event.key === "Enter") {
1693
+ event.preventDefault();
1694
+ void handleSubmit();
1695
+ }
1696
+ },
1697
+ placeholder: copy.threadResponsePlaceholder,
1698
+ className: "h-24 w-full resize-none rounded-2xl border border-slate-300 px-3 py-2 text-sm text-slate-800 outline-none transition focus:border-slate-500 focus:ring-2 focus:ring-slate-200",
1699
+ disabled: isSubmitting
1700
+ }
1701
+ ),
1702
+ submitError ? /* @__PURE__ */ jsx2(
1703
+ "div",
1704
+ {
1705
+ className: "rounded-xl border border-rose-200 bg-rose-50 px-3 py-2 text-xs text-rose-700",
1706
+ role: "alert",
1707
+ children: submitError
1708
+ }
1709
+ ) : null,
1710
+ /* @__PURE__ */ jsx2("div", { className: "flex justify-end", children: /* @__PURE__ */ jsx2(
1711
+ "button",
1712
+ {
1713
+ type: "button",
1714
+ className: cn(
1715
+ "rounded-full px-4 py-2 text-sm font-semibold text-white transition",
1716
+ isValid && !isSubmitting ? "bg-slate-900 hover:bg-slate-800" : "cursor-not-allowed bg-slate-300"
1717
+ ),
1718
+ onClick: () => void handleSubmit(),
1719
+ disabled: !isValid || isSubmitting,
1720
+ children: isSubmitting ? copy.threadResponseSubmittingAction : copy.threadResponseSubmitAction
1721
+ }
1722
+ ) })
1723
+ ] });
1724
+ }
1725
+ function IssueReportMessageThread({
1726
+ issueReportId
1727
+ }) {
1728
+ const { copy, client, setNeedsResponse } = useIssueReporting();
1729
+ const supportsMessages = Boolean(client.issueReporting.listMessages);
1730
+ const supportsSubmit = Boolean(client.issueReporting.submitMessage);
1731
+ const query = useIssueReportingMessages(issueReportId);
1732
+ const needsResponse = query.data?.needs_response ?? false;
1733
+ useEffect2(() => {
1734
+ setNeedsResponse(issueReportId, needsResponse);
1735
+ return () => {
1736
+ setNeedsResponse(issueReportId, false);
1737
+ };
1738
+ }, [issueReportId, needsResponse, setNeedsResponse]);
1739
+ if (!supportsMessages) {
1740
+ return null;
1741
+ }
1742
+ const visibleMessages = selectReporterVisibleMessages(query.data?.items ?? []);
1743
+ return /* @__PURE__ */ jsxs(
1744
+ "section",
1745
+ {
1746
+ "aria-label": copy.threadTitle,
1747
+ className: "mt-6 border-t border-slate-100 pt-5",
1748
+ children: [
1749
+ /* @__PURE__ */ jsxs("div", { className: "flex items-center justify-between gap-2", children: [
1750
+ /* @__PURE__ */ jsx2("h3", { className: "text-sm font-semibold text-slate-900", children: copy.threadTitle }),
1751
+ needsResponse ? /* @__PURE__ */ jsx2(
1752
+ "span",
1753
+ {
1754
+ className: cn(
1755
+ "rounded-full px-2 py-0.5 font-medium",
1756
+ BADGE_TEXT,
1757
+ "bg-amber-100 text-amber-700"
1758
+ ),
1759
+ children: copy.threadNeedsResponseBadge
1760
+ }
1761
+ ) : null
1762
+ ] }),
1763
+ /* @__PURE__ */ jsx2("p", { className: "mt-1 text-xs text-slate-500", children: copy.threadDescription }),
1764
+ query.isPending ? /* @__PURE__ */ jsxs("div", { className: "mt-4 flex items-center gap-2 rounded-2xl border border-slate-200 bg-slate-50 px-4 py-4 text-sm text-slate-600", children: [
1765
+ /* @__PURE__ */ jsx2(Spinner, { className: "h-4 w-4 animate-spin" }),
1766
+ /* @__PURE__ */ jsx2("span", { children: copy.threadLoading })
1767
+ ] }) : query.error ? /* @__PURE__ */ jsxs("div", { className: "mt-4 space-y-3 rounded-2xl border border-rose-200 bg-rose-50 px-4 py-4 text-sm text-rose-700", children: [
1768
+ /* @__PURE__ */ jsx2("div", { children: resolveErrorMessage(query.error, copy.threadLoadFailed) }),
1769
+ /* @__PURE__ */ jsx2(
1770
+ "button",
1771
+ {
1772
+ type: "button",
1773
+ className: "rounded-full border border-rose-300 px-3 py-1 font-medium transition hover:bg-rose-100",
1774
+ onClick: () => void query.refetch(),
1775
+ children: copy.retryAction
1776
+ }
1777
+ )
1778
+ ] }) : visibleMessages.length === 0 ? /* @__PURE__ */ jsx2("div", { className: "mt-4 rounded-2xl border border-dashed border-slate-200 bg-slate-50 px-4 py-6 text-center text-sm text-slate-500", children: copy.threadEmpty }) : /* @__PURE__ */ jsx2("ul", { className: "mt-4 space-y-3", children: visibleMessages.map((message) => /* @__PURE__ */ jsx2(MessageBubble, { message, copy }, message.id)) }),
1779
+ supportsSubmit ? /* @__PURE__ */ jsx2(
1780
+ ReporterResponseComposer,
1781
+ {
1782
+ issueReportId,
1783
+ copy
1784
+ }
1785
+ ) : null
1786
+ ]
1787
+ }
1788
+ );
1789
+ }
1466
1790
  function IssueReportModeBanner() {
1467
1791
  const { copy } = useIssueReporting();
1468
1792
  const reportMode = useReportMode();
@@ -2014,33 +2338,44 @@ function FloatingIssueReportButton({
2014
2338
  isReportMode,
2015
2339
  isPopoverOpen,
2016
2340
  openPopover,
2017
- closePopover
2341
+ closePopover,
2342
+ needsResponseIssueIds
2018
2343
  } = useIssueReporting();
2019
2344
  const status = useIssueReportingStatus();
2020
2345
  const entryPointState = getEntryPointState(status.data);
2346
+ const needsResponse = needsResponseIssueIds.length > 0;
2021
2347
  if (!isEligible) {
2022
2348
  return null;
2023
2349
  }
2024
2350
  return /* @__PURE__ */ jsxs(Fragment, { children: [
2025
2351
  /* @__PURE__ */ jsx2(IssueReportModeBanner, {}),
2026
- !isReportMode ? /* @__PURE__ */ jsx2("div", { className: cn("fixed bottom-12 right-4", Z_FLOATING_BUTTON, positionClassName), children: /* @__PURE__ */ jsx2(IssueReportPopover, { children: /* @__PURE__ */ jsx2(
2352
+ !isReportMode ? /* @__PURE__ */ jsx2("div", { className: cn("fixed bottom-12 right-4", Z_FLOATING_BUTTON, positionClassName), children: /* @__PURE__ */ jsx2(IssueReportPopover, { children: /* @__PURE__ */ jsxs(
2027
2353
  "button",
2028
2354
  {
2029
2355
  type: "button",
2030
- "aria-label": copy.entryAriaLabel,
2356
+ "aria-label": needsResponse ? `${copy.entryAriaLabel} (${copy.threadNeedsResponseBadge})` : copy.entryAriaLabel,
2031
2357
  onClick: () => isPopoverOpen ? closePopover() : openPopover(),
2032
2358
  className: cn(
2033
- "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",
2359
+ "relative 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",
2034
2360
  status.isPending && "animate-pulse",
2035
2361
  className
2036
2362
  ),
2037
- children: /* @__PURE__ */ jsx2(
2038
- BugBeetle,
2039
- {
2040
- className: cn("h-6 w-6", getEntryPointClassName(entryPointState)),
2041
- weight: "fill"
2042
- }
2043
- )
2363
+ children: [
2364
+ /* @__PURE__ */ jsx2(
2365
+ BugBeetle,
2366
+ {
2367
+ className: cn("h-6 w-6", getEntryPointClassName(entryPointState)),
2368
+ weight: "fill"
2369
+ }
2370
+ ),
2371
+ needsResponse ? /* @__PURE__ */ jsx2(
2372
+ "span",
2373
+ {
2374
+ "data-testid": "issue-report-needs-response-badge",
2375
+ className: "absolute -right-0.5 -top-0.5 h-3 w-3 rounded-full border-2 border-white bg-amber-500"
2376
+ }
2377
+ ) : null
2378
+ ]
2044
2379
  }
2045
2380
  ) }) }) : null,
2046
2381
  /* @__PURE__ */ jsx2(IssueReportModal, {})
@@ -2092,6 +2427,7 @@ function ReportableSection({
2092
2427
  }
2093
2428
  export {
2094
2429
  FloatingIssueReportButton,
2430
+ IssueReportMessageThread,
2095
2431
  IssueReportingPageConfig,
2096
2432
  IssueReportingProvider,
2097
2433
  ReportModeContext,
@@ -2105,9 +2441,13 @@ export {
2105
2441
  getIssueStatusClassName,
2106
2442
  isClosedIssueStatus,
2107
2443
  isOpenIssueStatus,
2444
+ isReporterMessageConflict,
2108
2445
  issueReportingKeys,
2446
+ selectReporterVisibleMessages,
2109
2447
  useIssueReporting,
2110
2448
  useIssueReportingHistory,
2449
+ useIssueReportingMessageMutation,
2450
+ useIssueReportingMessages,
2111
2451
  useIssueReportingMutations,
2112
2452
  useIssueReportingStatus,
2113
2453
  useReportMode
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "spaps-issue-reporting-react",
3
- "version": "0.4.1",
3
+ "version": "0.4.2",
4
4
  "description": "Shared React issue-reporting UI for Sweet Potato platform consumers",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -40,7 +40,7 @@
40
40
  "@radix-ui/react-dialog": "^1.1.15",
41
41
  "@radix-ui/react-popover": "^1.1.15",
42
42
  "date-fns": "^4.1.0",
43
- "spaps-types": "^1.4.0"
43
+ "spaps-types": "^1.4.2"
44
44
  },
45
45
  "peerDependencies": {
46
46
  "@tanstack/react-query": ">=5.0.0",