feedtack 0.3.1 → 0.4.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.d.ts CHANGED
@@ -50,7 +50,10 @@ declare class WebhookAdapter implements FeedtackAdapter {
50
50
  declare function getViewportMeta(): FeedtackViewportMeta;
51
51
  declare function getPageMeta(): FeedtackPageMeta;
52
52
  declare function getDeviceMeta(): FeedtackDeviceMeta;
53
- declare function getPinCoords(event: MouseEvent): {
53
+ declare function getPinCoords(event: {
54
+ clientX: number;
55
+ clientY: number;
56
+ }): {
54
57
  x: number;
55
58
  y: number;
56
59
  xPct: number;
@@ -1,6 +1,11 @@
1
1
  import * as react_jsx_runtime from 'react/jsx-runtime';
2
2
  import React from 'react';
3
- import { F as FeedtackAdapter, o as FeedtackUser, n as FeedtackTheme, e as FeedbackItem } from '../theme-C-uctIoI.js';
3
+ import { e as FeedbackItem, F as FeedtackAdapter, o as FeedtackUser, n as FeedtackTheme } from '../theme-C-uctIoI.js';
4
+
5
+ interface FeedtackFlushEvent {
6
+ pathname: string;
7
+ items: FeedbackItem[];
8
+ }
4
9
 
5
10
  interface FeedtackClasses {
6
11
  button?: string;
@@ -26,8 +31,14 @@ interface FeedtackProviderProps {
26
31
  disabled?: boolean;
27
32
  /** Render custom content inside a submitted pin marker. Receives the feedback item. */
28
33
  renderPinIcon?: (item: FeedbackItem) => React.ReactNode;
34
+ /** Called with batched feedback when user leaves a page or goes idle */
35
+ onFlush?: (event: FeedtackFlushEvent) => void;
36
+ /** Idle timeout in ms before flushing (default 5 min) */
37
+ flushIdleMs?: number;
38
+ /** User roles that trigger re-scope on reply (default: any non-'agent' role) */
39
+ rescopeRoles?: string[];
29
40
  }
30
- declare function FeedtackProvider({ children, adapter, currentUser, hotkey, adminOnly, theme, classes, sentimentLabels, onError, disabled, renderPinIcon, }: FeedtackProviderProps): react_jsx_runtime.JSX.Element;
41
+ declare function FeedtackProvider({ children, adapter, currentUser, hotkey, adminOnly, theme, classes, sentimentLabels, onError, disabled, renderPinIcon, onFlush, flushIdleMs, rescopeRoles, }: FeedtackProviderProps): react_jsx_runtime.JSX.Element;
31
42
 
32
43
  interface FeedtackContextValue {
33
44
  activatePinMode: () => void;
@@ -38,4 +49,4 @@ interface FeedtackContextValue {
38
49
  /** Hook for host app to programmatically control feedtack */
39
50
  declare function useFeedtack(): FeedtackContextValue;
40
51
 
41
- export { type FeedtackClasses, FeedtackProvider, type FeedtackProviderProps, type FeedtackSentimentLabels, useFeedtack };
52
+ export { type FeedtackClasses, type FeedtackFlushEvent, FeedtackProvider, type FeedtackProviderProps, type FeedtackSentimentLabels, useFeedtack };
@@ -191,6 +191,10 @@ function ThreadPanel({
191
191
  padding: 6,
192
192
  borderRadius: 6,
193
193
  border: "1px solid var(--ft-border)",
194
+ background: "var(--ft-surface)",
195
+ color: "var(--ft-text)",
196
+ minHeight: 60,
197
+ resize: "vertical",
194
198
  marginTop: 4
195
199
  }
196
200
  }
@@ -243,14 +247,14 @@ function ThreadPanel({
243
247
  }
244
248
 
245
249
  // src/react/useFeedtackState.ts
246
- import { useCallback as useCallback2, useEffect as useEffect3, useState as useState2 } from "react";
250
+ import { useCallback as useCallback3, useEffect as useEffect4, useState as useState2 } from "react";
247
251
 
248
252
  // src/react/useFeedtackDom.ts
249
253
  import { useEffect, useRef } from "react";
250
254
 
251
255
  // src/ui/styles.ts
252
256
  var FEEDTACK_DEFAULT_TOKENS = `
253
- #feedtack-root {
257
+ #feedtack-root, .feedtack-form, .feedtack-thread {
254
258
  --ft-primary: #2563eb;
255
259
  --ft-primary-hover: #1d4ed8;
256
260
  --ft-bg: #ffffff;
@@ -532,8 +536,68 @@ function useFeedtackDom(theme, disabled) {
532
536
  return rootRef;
533
537
  }
534
538
 
539
+ // src/react/useFeedtackFlush.ts
540
+ import { useCallback, useEffect as useEffect2, useRef as useRef2 } from "react";
541
+ var DEFAULT_IDLE_MS = 5 * 60 * 1e3;
542
+ function useFeedtackFlush({
543
+ pathname,
544
+ feedbackItems,
545
+ onFlush,
546
+ flushIdleMs = DEFAULT_IDLE_MS,
547
+ disabled
548
+ }) {
549
+ const flushedRef = useRef2(/* @__PURE__ */ new Set());
550
+ const prevPathnameRef = useRef2(pathname);
551
+ const idleTimerRef = useRef2(null);
552
+ const flush = useCallback(
553
+ (path, items) => {
554
+ if (!onFlush || flushedRef.current.has(path)) return;
555
+ const pageItems = items.filter((i) => i.payload.page.pathname === path);
556
+ if (pageItems.length === 0) return;
557
+ flushedRef.current.add(path);
558
+ onFlush({ pathname: path, items: pageItems });
559
+ },
560
+ [onFlush]
561
+ );
562
+ useEffect2(() => {
563
+ if (disabled || !onFlush) return;
564
+ const prev = prevPathnameRef.current;
565
+ prevPathnameRef.current = pathname;
566
+ if (prev !== pathname) {
567
+ flush(prev, feedbackItems);
568
+ }
569
+ }, [pathname, feedbackItems, flush, onFlush, disabled]);
570
+ useEffect2(() => {
571
+ if (disabled || !onFlush || flushIdleMs <= 0) return;
572
+ const resetTimer = () => {
573
+ if (idleTimerRef.current) clearTimeout(idleTimerRef.current);
574
+ idleTimerRef.current = setTimeout(() => {
575
+ flush(pathname, feedbackItems);
576
+ }, flushIdleMs);
577
+ };
578
+ const events = ["mousemove", "keydown", "scroll", "touchstart"];
579
+ for (const e of events)
580
+ window.addEventListener(e, resetTimer, { passive: true });
581
+ resetTimer();
582
+ return () => {
583
+ if (idleTimerRef.current) clearTimeout(idleTimerRef.current);
584
+ for (const e of events) window.removeEventListener(e, resetTimer);
585
+ };
586
+ }, [pathname, feedbackItems, flush, onFlush, flushIdleMs, disabled]);
587
+ useEffect2(() => {
588
+ if (disabled || !onFlush) return;
589
+ const handleUnload = () => flush(pathname, feedbackItems);
590
+ window.addEventListener("beforeunload", handleUnload);
591
+ return () => window.removeEventListener("beforeunload", handleUnload);
592
+ }, [pathname, feedbackItems, flush, onFlush, disabled]);
593
+ const clearFlushed = useCallback((path) => {
594
+ flushedRef.current.delete(path);
595
+ }, []);
596
+ return { clearFlushed };
597
+ }
598
+
535
599
  // src/react/usePinMode.ts
536
- import { useCallback, useEffect as useEffect2, useState } from "react";
600
+ import { useCallback as useCallback2, useEffect as useEffect3, useState } from "react";
537
601
  function usePinMode({
538
602
  hotkey,
539
603
  onDeactivate,
@@ -544,14 +608,14 @@ function usePinMode({
544
608
  const [pendingPins, setPendingPins] = useState([]);
545
609
  const [selectedColor, setSelectedColor] = useState(PIN_PALETTE[0]);
546
610
  const [showForm, setShowForm] = useState(false);
547
- const activate = useCallback(() => setIsActive(true), []);
548
- const deactivate = useCallback(() => {
611
+ const activate = useCallback2(() => setIsActive(true), []);
612
+ const deactivate = useCallback2(() => {
549
613
  setIsActive(false);
550
614
  setPendingPins([]);
551
615
  setShowForm(false);
552
616
  onDeactivate?.();
553
617
  }, [onDeactivate]);
554
- useEffect2(() => {
618
+ useEffect3(() => {
555
619
  if (isActive) {
556
620
  document.documentElement.classList.add("feedtack-crosshair");
557
621
  } else {
@@ -559,7 +623,7 @@ function usePinMode({
559
623
  }
560
624
  return () => document.documentElement.classList.remove("feedtack-crosshair");
561
625
  }, [isActive]);
562
- useEffect2(() => {
626
+ useEffect3(() => {
563
627
  if (disabled) return;
564
628
  const handler = (e) => {
565
629
  if (e.key === hotkey.toUpperCase() && e.shiftKey) {
@@ -580,31 +644,52 @@ function usePinMode({
580
644
  window.addEventListener("keydown", handler);
581
645
  return () => window.removeEventListener("keydown", handler);
582
646
  }, [hotkey, deactivate, isActive, disabled, isModalOpen, showForm]);
583
- const handlePageClick = useCallback(
584
- (e) => {
585
- if (!isActive) return;
586
- const target = e.target;
647
+ const placePin = useCallback2(
648
+ (coords, target) => {
587
649
  if (target.closest("#feedtack-root, .feedtack-form, .feedtack-color-picker"))
588
650
  return;
589
- e.preventDefault();
590
- e.stopPropagation();
591
651
  setPendingPins((prev) => [
592
652
  ...prev,
593
653
  {
594
654
  color: selectedColor,
595
- ...getPinCoords(e),
655
+ ...getPinCoords(coords),
596
656
  target: getTargetMeta(target)
597
657
  }
598
658
  ]);
599
659
  setShowForm(true);
600
660
  },
601
- [isActive, selectedColor]
661
+ [selectedColor]
602
662
  );
603
- useEffect2(() => {
663
+ const handlePageClick = useCallback2(
664
+ (e) => {
665
+ if (!isActive) return;
666
+ e.preventDefault();
667
+ e.stopPropagation();
668
+ placePin(e, e.target);
669
+ },
670
+ [isActive, placePin]
671
+ );
672
+ const handleTouchEnd = useCallback2(
673
+ (e) => {
674
+ if (!isActive) return;
675
+ const touch = e.changedTouches[0];
676
+ if (!touch) return;
677
+ const target = document.elementFromPoint(touch.clientX, touch.clientY);
678
+ if (!target) return;
679
+ e.preventDefault();
680
+ placePin(touch, target);
681
+ },
682
+ [isActive, placePin]
683
+ );
684
+ useEffect3(() => {
604
685
  if (disabled) return;
605
686
  document.addEventListener("click", handlePageClick, true);
606
- return () => document.removeEventListener("click", handlePageClick, true);
607
- }, [handlePageClick, disabled]);
687
+ document.addEventListener("touchend", handleTouchEnd, true);
688
+ return () => {
689
+ document.removeEventListener("click", handlePageClick, true);
690
+ document.removeEventListener("touchend", handleTouchEnd, true);
691
+ };
692
+ }, [handlePageClick, handleTouchEnd, disabled]);
608
693
  return {
609
694
  isActive,
610
695
  activate,
@@ -623,11 +708,16 @@ function useFeedtackState({
623
708
  hotkey,
624
709
  theme,
625
710
  onError,
626
- disabled
711
+ disabled,
712
+ onFlush,
713
+ flushIdleMs,
714
+ rescopeRoles
627
715
  }) {
628
716
  useFeedtackDom(theme, disabled);
629
- const [pathname, setPathname] = useState2(() => window.location.pathname);
630
- useEffect3(() => {
717
+ const [pathname, setPathname] = useState2(
718
+ () => typeof window === "undefined" ? "/" : window.location.pathname
719
+ );
720
+ useEffect4(() => {
631
721
  const update = () => setPathname(window.location.pathname);
632
722
  const origPush = history.pushState.bind(history);
633
723
  const origReplace = history.replaceState.bind(history);
@@ -654,7 +744,7 @@ function useFeedtackState({
654
744
  const [loading, setLoading] = useState2(true);
655
745
  const [openThreadId, setOpenThreadId] = useState2(null);
656
746
  const [replyBody, setReplyBody] = useState2("");
657
- const resetForm = useCallback2(() => {
747
+ const resetForm = useCallback3(() => {
658
748
  setComment("");
659
749
  setSentiment(null);
660
750
  setCommentError(false);
@@ -668,7 +758,14 @@ function useFeedtackState({
668
758
  setOpenThreadId(null);
669
759
  }
670
760
  });
671
- useEffect3(() => {
761
+ const { clearFlushed } = useFeedtackFlush({
762
+ pathname,
763
+ feedbackItems,
764
+ onFlush,
765
+ flushIdleMs,
766
+ disabled
767
+ });
768
+ useEffect4(() => {
672
769
  setLoading(true);
673
770
  adapter.loadFeedback({ pathname }).then(setFeedbackItems).catch((err) => onError?.(err)).finally(() => setLoading(false));
674
771
  }, [adapter, onError, pathname]);
@@ -716,19 +813,26 @@ function useFeedtackState({
716
813
  body,
717
814
  timestamp: ts
718
815
  });
719
- updateItem(feedbackId, (item) => ({
720
- ...item,
721
- replies: [
722
- ...item.replies,
723
- {
724
- id: generateId(),
725
- feedbackId,
726
- author: currentUser,
727
- body,
728
- timestamp: ts
729
- }
730
- ]
731
- }));
816
+ updateItem(feedbackId, (item) => {
817
+ const updated = {
818
+ ...item,
819
+ replies: [
820
+ ...item.replies,
821
+ {
822
+ id: generateId(),
823
+ feedbackId,
824
+ author: currentUser,
825
+ body,
826
+ timestamp: ts
827
+ }
828
+ ]
829
+ };
830
+ const triggerRescope = rescopeRoles ? rescopeRoles.includes(currentUser.role) : currentUser.role !== "agent";
831
+ if (triggerRescope && updated.resolutions.length === 0 && onFlush) {
832
+ clearFlushed(pathname);
833
+ }
834
+ return updated;
835
+ });
732
836
  setReplyBody("");
733
837
  } catch (err) {
734
838
  onError?.(err);
@@ -768,6 +872,9 @@ function useFeedtackState({
768
872
  onError?.(err);
769
873
  }
770
874
  };
875
+ const isArchivedForUser = (item) => item.archives.some((a) => a.archivedBy.id === currentUser.id);
876
+ const hasUnread = (item) => item.replies.length > 0;
877
+ const hasValidPins = (item) => Array.isArray(item.payload?.pins) && item.payload.pins.length > 0;
771
878
  return {
772
879
  ...pinMode,
773
880
  isPinModeActive: pinMode.isActive,
@@ -791,9 +898,9 @@ function useFeedtackState({
791
898
  handleReply,
792
899
  handleResolve,
793
900
  handleArchive,
794
- isArchivedForUser: (item) => item.archives.some((a) => a.archivedBy.id === currentUser.id),
795
- hasUnread: (item) => item.replies.length > 0,
796
- hasValidPins: (item) => Array.isArray(item.payload?.pins) && item.payload.pins.length > 0
901
+ isArchivedForUser,
902
+ hasUnread,
903
+ hasValidPins
797
904
  };
798
905
  }
799
906
 
@@ -810,7 +917,10 @@ function FeedtackProvider({
810
917
  sentimentLabels = {},
811
918
  onError,
812
919
  disabled = false,
813
- renderPinIcon
920
+ renderPinIcon,
921
+ onFlush,
922
+ flushIdleMs,
923
+ rescopeRoles
814
924
  }) {
815
925
  const state = useFeedtackState({
816
926
  adapter,
@@ -818,7 +928,10 @@ function FeedtackProvider({
818
928
  hotkey,
819
929
  theme,
820
930
  onError,
821
- disabled
931
+ disabled,
932
+ onFlush,
933
+ flushIdleMs,
934
+ rescopeRoles
822
935
  });
823
936
  const firstPin = state.pendingPins[0];
824
937
  const formPos = firstPin ? getAnchoredPosition(firstPin.x, firstPin.y) : {};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "feedtack",
3
- "version": "0.3.1",
3
+ "version": "0.4.0",
4
4
  "description": "Click anywhere. Drop a pin. Get a payload a developer can act on.",
5
5
  "type": "module",
6
6
  "license": "MIT",