feedtack 0.3.1 → 0.5.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 };
@@ -155,11 +155,13 @@ function ThreadPanel({
155
155
  onResolve,
156
156
  onArchive,
157
157
  onClose,
158
- className
158
+ className,
159
+ pinPosition
159
160
  }) {
160
161
  const pin = item.payload?.pins?.[0];
161
162
  if (!pin) return null;
162
- const pos = getAnchoredPosition(pin.x, pin.y);
163
+ const { x, y } = pinPosition ?? pin;
164
+ const pos = getAnchoredPosition(x, y);
163
165
  return /* @__PURE__ */ jsxs2(
164
166
  "div",
165
167
  {
@@ -191,6 +193,10 @@ function ThreadPanel({
191
193
  padding: 6,
192
194
  borderRadius: 6,
193
195
  border: "1px solid var(--ft-border)",
196
+ background: "var(--ft-surface)",
197
+ color: "var(--ft-text)",
198
+ minHeight: 60,
199
+ resize: "vertical",
194
200
  marginTop: 4
195
201
  }
196
202
  }
@@ -242,15 +248,199 @@ function ThreadPanel({
242
248
  );
243
249
  }
244
250
 
251
+ // src/react/useAnchoredPins.ts
252
+ import { useCallback, useEffect, useState } from "react";
253
+ function useAnchoredPins(items, pathname) {
254
+ const [positions, setPositions] = useState(
255
+ /* @__PURE__ */ new Map()
256
+ );
257
+ const resolve = useCallback(() => {
258
+ const next = /* @__PURE__ */ new Map();
259
+ for (const item of items) {
260
+ if (item.payload.page.pathname !== pathname) continue;
261
+ const pin = item.payload.pins[0];
262
+ if (!pin) continue;
263
+ const pos = resolvePin(pin);
264
+ next.set(item.payload.id, pos);
265
+ }
266
+ setPositions(next);
267
+ }, [items, pathname]);
268
+ useEffect(() => {
269
+ resolve();
270
+ }, [resolve]);
271
+ useEffect(() => {
272
+ let raf = 0;
273
+ const handler = () => {
274
+ cancelAnimationFrame(raf);
275
+ raf = requestAnimationFrame(resolve);
276
+ };
277
+ window.addEventListener("resize", handler, { passive: true });
278
+ window.addEventListener("scroll", handler, { passive: true });
279
+ return () => {
280
+ cancelAnimationFrame(raf);
281
+ window.removeEventListener("resize", handler);
282
+ window.removeEventListener("scroll", handler);
283
+ };
284
+ }, [resolve]);
285
+ const getPosition = useCallback(
286
+ (itemId, fallbackPin) => {
287
+ return positions.get(itemId) ?? { x: fallbackPin.x, y: fallbackPin.y };
288
+ },
289
+ [positions]
290
+ );
291
+ return { getPosition };
292
+ }
293
+ function resolvePin(pin) {
294
+ const { target } = pin;
295
+ if (!target.selector) return { x: pin.x, y: pin.y };
296
+ let el = null;
297
+ try {
298
+ el = document.querySelector(target.selector);
299
+ } catch {
300
+ return { x: pin.x, y: pin.y };
301
+ }
302
+ if (!el) return { x: pin.x, y: pin.y };
303
+ const rect = el.getBoundingClientRect();
304
+ const origRect = target.boundingRect;
305
+ const ratioX = origRect.width > 0 ? (pin.x - origRect.x) / origRect.width : 0.5;
306
+ const ratioY = origRect.height > 0 ? (pin.y - origRect.y) / origRect.height : 0.5;
307
+ const x = rect.x + window.scrollX + ratioX * rect.width;
308
+ const y = rect.y + window.scrollY + ratioY * rect.height;
309
+ return { x, y };
310
+ }
311
+
245
312
  // src/react/useFeedtackState.ts
246
- import { useCallback as useCallback2, useEffect as useEffect3, useState as useState2 } from "react";
313
+ import { useCallback as useCallback5, useEffect as useEffect5, useState as useState3 } from "react";
314
+
315
+ // src/react/useFeedtackActions.ts
316
+ import { useCallback as useCallback2 } from "react";
317
+ function useFeedtackActions(deps) {
318
+ const { adapter, currentUser, onError } = deps;
319
+ const updateItem = useCallback2(
320
+ (id, fn) => deps.setFeedbackItems(
321
+ (prev) => prev.map((i) => i.payload.id === id ? fn(i) : i)
322
+ ),
323
+ [deps.setFeedbackItems]
324
+ );
325
+ const handleSubmit = useCallback2(async () => {
326
+ const comment = deps.getComment();
327
+ if (!comment.trim()) {
328
+ deps.setCommentError(true);
329
+ return;
330
+ }
331
+ deps.setSubmitting(true);
332
+ const payload = {
333
+ schemaVersion: SCHEMA_VERSION,
334
+ id: generateId(),
335
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
336
+ submittedBy: currentUser,
337
+ comment: comment.trim(),
338
+ sentiment: deps.getSentiment(),
339
+ pins: deps.getPendingPins().map((p, i) => ({ ...p, index: i + 1 })),
340
+ page: getPageMeta(),
341
+ viewport: getViewportMeta(),
342
+ device: getDeviceMeta()
343
+ };
344
+ try {
345
+ await adapter.submit(payload);
346
+ deps.setFeedbackItems((prev) => [
347
+ ...prev,
348
+ { payload, replies: [], resolutions: [], archives: [] }
349
+ ]);
350
+ deps.deactivatePinMode();
351
+ } catch (err) {
352
+ onError?.(err);
353
+ } finally {
354
+ deps.setSubmitting(false);
355
+ }
356
+ }, [adapter, currentUser, onError, deps]);
357
+ const handleReply = useCallback2(
358
+ async (feedbackId) => {
359
+ const body = deps.getReplyBody().trim();
360
+ if (!body) return;
361
+ const ts = (/* @__PURE__ */ new Date()).toISOString();
362
+ try {
363
+ await adapter.reply(feedbackId, {
364
+ author: currentUser,
365
+ body,
366
+ timestamp: ts
367
+ });
368
+ updateItem(feedbackId, (item) => {
369
+ const updated = {
370
+ ...item,
371
+ replies: [
372
+ ...item.replies,
373
+ {
374
+ id: generateId(),
375
+ feedbackId,
376
+ author: currentUser,
377
+ body,
378
+ timestamp: ts
379
+ }
380
+ ]
381
+ };
382
+ const rescope = deps.shouldRescope?.(currentUser.role) ?? currentUser.role !== "agent";
383
+ if (rescope && updated.resolutions.length === 0 && deps.hasFlush) {
384
+ deps.clearFlushed?.(deps.getPathname());
385
+ }
386
+ return updated;
387
+ });
388
+ deps.setReplyBody("");
389
+ } catch (err) {
390
+ onError?.(err);
391
+ }
392
+ },
393
+ [adapter, currentUser, onError, updateItem, deps]
394
+ );
395
+ const handleResolve = useCallback2(
396
+ async (feedbackId) => {
397
+ const ts = (/* @__PURE__ */ new Date()).toISOString();
398
+ try {
399
+ await adapter.resolve(feedbackId, {
400
+ resolvedBy: currentUser,
401
+ timestamp: ts
402
+ });
403
+ updateItem(feedbackId, (item) => ({
404
+ ...item,
405
+ resolutions: [
406
+ ...item.resolutions,
407
+ { feedbackId, resolvedBy: currentUser, timestamp: ts }
408
+ ]
409
+ }));
410
+ } catch (err) {
411
+ onError?.(err);
412
+ }
413
+ },
414
+ [adapter, currentUser, onError, updateItem]
415
+ );
416
+ const handleArchive = useCallback2(
417
+ async (feedbackId) => {
418
+ const ts = (/* @__PURE__ */ new Date()).toISOString();
419
+ try {
420
+ await adapter.archive(feedbackId, currentUser.id);
421
+ updateItem(feedbackId, (item) => ({
422
+ ...item,
423
+ archives: [
424
+ ...item.archives,
425
+ { feedbackId, archivedBy: currentUser, timestamp: ts }
426
+ ]
427
+ }));
428
+ deps.setOpenThreadId(null);
429
+ } catch (err) {
430
+ onError?.(err);
431
+ }
432
+ },
433
+ [adapter, currentUser, onError, updateItem, deps]
434
+ );
435
+ return { handleSubmit, handleReply, handleResolve, handleArchive };
436
+ }
247
437
 
248
438
  // src/react/useFeedtackDom.ts
249
- import { useEffect, useRef } from "react";
439
+ import { useEffect as useEffect2, useRef } from "react";
250
440
 
251
441
  // src/ui/styles.ts
252
442
  var FEEDTACK_DEFAULT_TOKENS = `
253
- #feedtack-root {
443
+ #feedtack-root, .feedtack-form, .feedtack-thread {
254
444
  --ft-primary: #2563eb;
255
445
  --ft-primary-hover: #1d4ed8;
256
446
  --ft-bg: #ffffff;
@@ -499,7 +689,7 @@ var FEEDTACK_STYLES = `
499
689
  // src/react/useFeedtackDom.ts
500
690
  function useFeedtackDom(theme, disabled) {
501
691
  const rootRef = useRef(null);
502
- useEffect(() => {
692
+ useEffect2(() => {
503
693
  if (disabled) return;
504
694
  if (document.getElementById("feedtack-styles")) return;
505
695
  const style = document.createElement("style");
@@ -510,7 +700,7 @@ function useFeedtackDom(theme, disabled) {
510
700
  style.remove();
511
701
  };
512
702
  }, [disabled]);
513
- useEffect(() => {
703
+ useEffect2(() => {
514
704
  if (disabled) return;
515
705
  const root = document.createElement("div");
516
706
  root.id = "feedtack-root";
@@ -520,7 +710,7 @@ function useFeedtackDom(theme, disabled) {
520
710
  root.remove();
521
711
  };
522
712
  }, [disabled]);
523
- useEffect(() => {
713
+ useEffect2(() => {
524
714
  if (disabled) return;
525
715
  const root = document.getElementById("feedtack-root");
526
716
  if (!root || !theme) return;
@@ -532,26 +722,86 @@ function useFeedtackDom(theme, disabled) {
532
722
  return rootRef;
533
723
  }
534
724
 
725
+ // src/react/useFeedtackFlush.ts
726
+ import { useCallback as useCallback3, useEffect as useEffect3, useRef as useRef2 } from "react";
727
+ var DEFAULT_IDLE_MS = 5 * 60 * 1e3;
728
+ function useFeedtackFlush({
729
+ pathname,
730
+ feedbackItems,
731
+ onFlush,
732
+ flushIdleMs = DEFAULT_IDLE_MS,
733
+ disabled
734
+ }) {
735
+ const flushedRef = useRef2(/* @__PURE__ */ new Set());
736
+ const prevPathnameRef = useRef2(pathname);
737
+ const idleTimerRef = useRef2(null);
738
+ const flush = useCallback3(
739
+ (path, items) => {
740
+ if (!onFlush || flushedRef.current.has(path)) return;
741
+ const pageItems = items.filter((i) => i.payload.page.pathname === path);
742
+ if (pageItems.length === 0) return;
743
+ flushedRef.current.add(path);
744
+ onFlush({ pathname: path, items: pageItems });
745
+ },
746
+ [onFlush]
747
+ );
748
+ useEffect3(() => {
749
+ if (disabled || !onFlush) return;
750
+ const prev = prevPathnameRef.current;
751
+ prevPathnameRef.current = pathname;
752
+ if (prev !== pathname) {
753
+ flush(prev, feedbackItems);
754
+ }
755
+ }, [pathname, feedbackItems, flush, onFlush, disabled]);
756
+ useEffect3(() => {
757
+ if (disabled || !onFlush || flushIdleMs <= 0) return;
758
+ const resetTimer = () => {
759
+ if (idleTimerRef.current) clearTimeout(idleTimerRef.current);
760
+ idleTimerRef.current = setTimeout(() => {
761
+ flush(pathname, feedbackItems);
762
+ }, flushIdleMs);
763
+ };
764
+ const events = ["mousemove", "keydown", "scroll", "touchstart"];
765
+ for (const e of events)
766
+ window.addEventListener(e, resetTimer, { passive: true });
767
+ resetTimer();
768
+ return () => {
769
+ if (idleTimerRef.current) clearTimeout(idleTimerRef.current);
770
+ for (const e of events) window.removeEventListener(e, resetTimer);
771
+ };
772
+ }, [pathname, feedbackItems, flush, onFlush, flushIdleMs, disabled]);
773
+ useEffect3(() => {
774
+ if (disabled || !onFlush) return;
775
+ const handleUnload = () => flush(pathname, feedbackItems);
776
+ window.addEventListener("beforeunload", handleUnload);
777
+ return () => window.removeEventListener("beforeunload", handleUnload);
778
+ }, [pathname, feedbackItems, flush, onFlush, disabled]);
779
+ const clearFlushed = useCallback3((path) => {
780
+ flushedRef.current.delete(path);
781
+ }, []);
782
+ return { clearFlushed };
783
+ }
784
+
535
785
  // src/react/usePinMode.ts
536
- import { useCallback, useEffect as useEffect2, useState } from "react";
786
+ import { useCallback as useCallback4, useEffect as useEffect4, useState as useState2 } from "react";
537
787
  function usePinMode({
538
788
  hotkey,
539
789
  onDeactivate,
540
790
  disabled,
541
791
  isModalOpen
542
792
  }) {
543
- const [isActive, setIsActive] = useState(false);
544
- const [pendingPins, setPendingPins] = useState([]);
545
- const [selectedColor, setSelectedColor] = useState(PIN_PALETTE[0]);
546
- const [showForm, setShowForm] = useState(false);
547
- const activate = useCallback(() => setIsActive(true), []);
548
- const deactivate = useCallback(() => {
793
+ const [isActive, setIsActive] = useState2(false);
794
+ const [pendingPins, setPendingPins] = useState2([]);
795
+ const [selectedColor, setSelectedColor] = useState2(PIN_PALETTE[0]);
796
+ const [showForm, setShowForm] = useState2(false);
797
+ const activate = useCallback4(() => setIsActive(true), []);
798
+ const deactivate = useCallback4(() => {
549
799
  setIsActive(false);
550
800
  setPendingPins([]);
551
801
  setShowForm(false);
552
802
  onDeactivate?.();
553
803
  }, [onDeactivate]);
554
- useEffect2(() => {
804
+ useEffect4(() => {
555
805
  if (isActive) {
556
806
  document.documentElement.classList.add("feedtack-crosshair");
557
807
  } else {
@@ -559,7 +809,7 @@ function usePinMode({
559
809
  }
560
810
  return () => document.documentElement.classList.remove("feedtack-crosshair");
561
811
  }, [isActive]);
562
- useEffect2(() => {
812
+ useEffect4(() => {
563
813
  if (disabled) return;
564
814
  const handler = (e) => {
565
815
  if (e.key === hotkey.toUpperCase() && e.shiftKey) {
@@ -580,31 +830,52 @@ function usePinMode({
580
830
  window.addEventListener("keydown", handler);
581
831
  return () => window.removeEventListener("keydown", handler);
582
832
  }, [hotkey, deactivate, isActive, disabled, isModalOpen, showForm]);
583
- const handlePageClick = useCallback(
584
- (e) => {
585
- if (!isActive) return;
586
- const target = e.target;
833
+ const placePin = useCallback4(
834
+ (coords, target) => {
587
835
  if (target.closest("#feedtack-root, .feedtack-form, .feedtack-color-picker"))
588
836
  return;
589
- e.preventDefault();
590
- e.stopPropagation();
591
837
  setPendingPins((prev) => [
592
838
  ...prev,
593
839
  {
594
840
  color: selectedColor,
595
- ...getPinCoords(e),
841
+ ...getPinCoords(coords),
596
842
  target: getTargetMeta(target)
597
843
  }
598
844
  ]);
599
845
  setShowForm(true);
600
846
  },
601
- [isActive, selectedColor]
847
+ [selectedColor]
602
848
  );
603
- useEffect2(() => {
849
+ const handlePageClick = useCallback4(
850
+ (e) => {
851
+ if (!isActive) return;
852
+ e.preventDefault();
853
+ e.stopPropagation();
854
+ placePin(e, e.target);
855
+ },
856
+ [isActive, placePin]
857
+ );
858
+ const handleTouchEnd = useCallback4(
859
+ (e) => {
860
+ if (!isActive) return;
861
+ const touch = e.changedTouches[0];
862
+ if (!touch) return;
863
+ const target = document.elementFromPoint(touch.clientX, touch.clientY);
864
+ if (!target) return;
865
+ e.preventDefault();
866
+ placePin(touch, target);
867
+ },
868
+ [isActive, placePin]
869
+ );
870
+ useEffect4(() => {
604
871
  if (disabled) return;
605
872
  document.addEventListener("click", handlePageClick, true);
606
- return () => document.removeEventListener("click", handlePageClick, true);
607
- }, [handlePageClick, disabled]);
873
+ document.addEventListener("touchend", handleTouchEnd, true);
874
+ return () => {
875
+ document.removeEventListener("click", handlePageClick, true);
876
+ document.removeEventListener("touchend", handleTouchEnd, true);
877
+ };
878
+ }, [handlePageClick, handleTouchEnd, disabled]);
608
879
  return {
609
880
  isActive,
610
881
  activate,
@@ -623,11 +894,16 @@ function useFeedtackState({
623
894
  hotkey,
624
895
  theme,
625
896
  onError,
626
- disabled
897
+ disabled,
898
+ onFlush,
899
+ flushIdleMs,
900
+ rescopeRoles
627
901
  }) {
628
902
  useFeedtackDom(theme, disabled);
629
- const [pathname, setPathname] = useState2(() => window.location.pathname);
630
- useEffect3(() => {
903
+ const [pathname, setPathname] = useState3(
904
+ () => typeof window === "undefined" ? "/" : window.location.pathname
905
+ );
906
+ useEffect5(() => {
631
907
  const update = () => setPathname(window.location.pathname);
632
908
  const origPush = history.pushState.bind(history);
633
909
  const origReplace = history.replaceState.bind(history);
@@ -646,15 +922,15 @@ function useFeedtackState({
646
922
  history.replaceState = origReplace;
647
923
  };
648
924
  }, []);
649
- const [comment, setComment] = useState2("");
650
- const [sentiment, setSentiment] = useState2(null);
651
- const [commentError, setCommentError] = useState2(false);
652
- const [submitting, setSubmitting] = useState2(false);
653
- const [feedbackItems, setFeedbackItems] = useState2([]);
654
- const [loading, setLoading] = useState2(true);
655
- const [openThreadId, setOpenThreadId] = useState2(null);
656
- const [replyBody, setReplyBody] = useState2("");
657
- const resetForm = useCallback2(() => {
925
+ const [comment, setComment] = useState3("");
926
+ const [sentiment, setSentiment] = useState3(null);
927
+ const [commentError, setCommentError] = useState3(false);
928
+ const [submitting, setSubmitting] = useState3(false);
929
+ const [feedbackItems, setFeedbackItems] = useState3([]);
930
+ const [loading, setLoading] = useState3(true);
931
+ const [openThreadId, setOpenThreadId] = useState3(null);
932
+ const [replyBody, setReplyBody] = useState3("");
933
+ const resetForm = useCallback5(() => {
658
934
  setComment("");
659
935
  setSentiment(null);
660
936
  setCommentError(false);
@@ -668,106 +944,44 @@ function useFeedtackState({
668
944
  setOpenThreadId(null);
669
945
  }
670
946
  });
671
- useEffect3(() => {
947
+ const { clearFlushed } = useFeedtackFlush({
948
+ pathname,
949
+ feedbackItems,
950
+ onFlush,
951
+ flushIdleMs,
952
+ disabled
953
+ });
954
+ useEffect5(() => {
672
955
  setLoading(true);
673
956
  adapter.loadFeedback({ pathname }).then(setFeedbackItems).catch((err) => onError?.(err)).finally(() => setLoading(false));
674
957
  }, [adapter, onError, pathname]);
675
- const updateItem = (id, fn) => setFeedbackItems(
676
- (prev) => prev.map((i) => i.payload.id === id ? fn(i) : i)
677
- );
678
- const handleSubmit = async () => {
679
- if (!comment.trim()) {
680
- setCommentError(true);
681
- return;
682
- }
683
- setSubmitting(true);
684
- const payload = {
685
- schemaVersion: SCHEMA_VERSION,
686
- id: generateId(),
687
- timestamp: (/* @__PURE__ */ new Date()).toISOString(),
688
- submittedBy: currentUser,
689
- comment: comment.trim(),
690
- sentiment,
691
- pins: pinMode.pendingPins.map((p, i) => ({ ...p, index: i + 1 })),
692
- page: getPageMeta(),
693
- viewport: getViewportMeta(),
694
- device: getDeviceMeta()
695
- };
696
- try {
697
- await adapter.submit(payload);
698
- setFeedbackItems((prev) => [
699
- ...prev,
700
- { payload, replies: [], resolutions: [], archives: [] }
701
- ]);
702
- pinMode.deactivate();
703
- } catch (err) {
704
- onError?.(err);
705
- } finally {
706
- setSubmitting(false);
707
- }
708
- };
709
- const handleReply = async (feedbackId) => {
710
- if (!replyBody.trim()) return;
711
- const ts = (/* @__PURE__ */ new Date()).toISOString();
712
- const body = replyBody.trim();
713
- try {
714
- await adapter.reply(feedbackId, {
715
- author: currentUser,
716
- body,
717
- timestamp: ts
718
- });
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
- }));
732
- setReplyBody("");
733
- } catch (err) {
734
- onError?.(err);
735
- }
736
- };
737
- const handleResolve = async (feedbackId) => {
738
- const ts = (/* @__PURE__ */ new Date()).toISOString();
739
- try {
740
- await adapter.resolve(feedbackId, {
741
- resolvedBy: currentUser,
742
- timestamp: ts
743
- });
744
- updateItem(feedbackId, (item) => ({
745
- ...item,
746
- resolutions: [
747
- ...item.resolutions,
748
- { feedbackId, resolvedBy: currentUser, timestamp: ts }
749
- ]
750
- }));
751
- } catch (err) {
752
- onError?.(err);
753
- }
754
- };
755
- const handleArchive = async (feedbackId) => {
756
- const ts = (/* @__PURE__ */ new Date()).toISOString();
757
- try {
758
- await adapter.archive(feedbackId, currentUser.id);
759
- updateItem(feedbackId, (item) => ({
760
- ...item,
761
- archives: [
762
- ...item.archives,
763
- { feedbackId, archivedBy: currentUser, timestamp: ts }
764
- ]
765
- }));
766
- setOpenThreadId(null);
767
- } catch (err) {
768
- onError?.(err);
769
- }
770
- };
958
+ const commentRef = () => comment;
959
+ const sentimentRef = () => sentiment;
960
+ const pinsRef = () => pinMode.pendingPins;
961
+ const replyRef = () => replyBody;
962
+ const pathRef = () => pathname;
963
+ const actions = useFeedtackActions({
964
+ adapter,
965
+ currentUser,
966
+ onError,
967
+ getComment: commentRef,
968
+ getSentiment: sentimentRef,
969
+ getPendingPins: pinsRef,
970
+ getReplyBody: replyRef,
971
+ getPathname: pathRef,
972
+ setCommentError,
973
+ setSubmitting,
974
+ setFeedbackItems,
975
+ setReplyBody,
976
+ setOpenThreadId,
977
+ deactivatePinMode: pinMode.deactivate,
978
+ clearFlushed,
979
+ shouldRescope: rescopeRoles ? (role) => rescopeRoles.includes(role) : void 0,
980
+ hasFlush: !!onFlush
981
+ });
982
+ const isArchivedForUser = (item) => item.archives.some((a) => a.archivedBy.id === currentUser.id);
983
+ const hasUnread = (item) => item.replies.length > 0;
984
+ const hasValidPins = (item) => Array.isArray(item.payload?.pins) && item.payload.pins.length > 0;
771
985
  return {
772
986
  ...pinMode,
773
987
  isPinModeActive: pinMode.isActive,
@@ -787,13 +1001,10 @@ function useFeedtackState({
787
1001
  setOpenThreadId,
788
1002
  replyBody,
789
1003
  setReplyBody,
790
- handleSubmit,
791
- handleReply,
792
- handleResolve,
793
- 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
1004
+ ...actions,
1005
+ isArchivedForUser,
1006
+ hasUnread,
1007
+ hasValidPins
797
1008
  };
798
1009
  }
799
1010
 
@@ -810,7 +1021,10 @@ function FeedtackProvider({
810
1021
  sentimentLabels = {},
811
1022
  onError,
812
1023
  disabled = false,
813
- renderPinIcon
1024
+ renderPinIcon,
1025
+ onFlush,
1026
+ flushIdleMs,
1027
+ rescopeRoles
814
1028
  }) {
815
1029
  const state = useFeedtackState({
816
1030
  adapter,
@@ -818,8 +1032,12 @@ function FeedtackProvider({
818
1032
  hotkey,
819
1033
  theme,
820
1034
  onError,
821
- disabled
1035
+ disabled,
1036
+ onFlush,
1037
+ flushIdleMs,
1038
+ rescopeRoles
822
1039
  });
1040
+ const { getPosition } = useAnchoredPins(state.feedbackItems, state.pathname);
823
1041
  const firstPin = state.pendingPins[0];
824
1042
  const formPos = firstPin ? getAnchoredPosition(firstPin.x, firstPin.y) : {};
825
1043
  const showButton = !adminOnly || currentUser.role === "admin";
@@ -904,6 +1122,7 @@ function FeedtackProvider({
904
1122
  ),
905
1123
  !state.loading && state.feedbackItems.filter((item) => item.payload.page.pathname === state.pathname).filter((item) => !state.isArchivedForUser(item)).filter((item) => state.hasValidPins(item)).map((item) => {
906
1124
  const pin = item.payload.pins[0];
1125
+ const pos = getPosition(item.payload.id, pin);
907
1126
  return /* @__PURE__ */ jsxs3(
908
1127
  "button",
909
1128
  {
@@ -915,8 +1134,8 @@ function FeedtackProvider({
915
1134
  ),
916
1135
  style: {
917
1136
  background: pin.color,
918
- left: pin.x,
919
- top: pin.y,
1137
+ left: pos.x,
1138
+ top: pos.y,
920
1139
  position: "absolute",
921
1140
  cursor: "pointer"
922
1141
  },
@@ -949,6 +1168,10 @@ function FeedtackProvider({
949
1168
  onResolve: () => state.handleResolve(openItem.payload.id),
950
1169
  onArchive: () => state.handleArchive(openItem.payload.id),
951
1170
  onClose: () => state.setOpenThreadId(null),
1171
+ pinPosition: getPosition(
1172
+ openItem.payload.id,
1173
+ openItem.payload.pins[0]
1174
+ ),
952
1175
  className: classes.thread
953
1176
  }
954
1177
  ),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "feedtack",
3
- "version": "0.3.1",
3
+ "version": "0.5.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",