feedtack 0.3.0 → 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/README.md CHANGED
@@ -74,6 +74,96 @@ class MySupabaseAdapter implements FeedtackAdapter {
74
74
  }
75
75
  ```
76
76
 
77
+ ## Adapter recipes
78
+
79
+ The `FeedtackAdapter` interface has five methods. Here are copy-paste implementations for common backends.
80
+
81
+ ### Disk / JSON files (Node.js)
82
+
83
+ Git-trackable feedback — each submission becomes a JSON file in `.feedback/`.
84
+
85
+ ```ts
86
+ import type { FeedtackAdapter, FeedbackItem, FeedtackPayload } from 'feedtack'
87
+ import { readdir, readFile, writeFile, mkdir } from 'node:fs/promises'
88
+ import { join } from 'node:path'
89
+
90
+ const DIR = '.feedback'
91
+
92
+ class DiskAdapter implements FeedtackAdapter {
93
+ async submit(payload: FeedtackPayload) {
94
+ await mkdir(DIR, { recursive: true })
95
+ const item: FeedbackItem = { payload, replies: [], resolutions: [], archives: [] }
96
+ await writeFile(join(DIR, `${payload.id}.json`), JSON.stringify(item, null, 2))
97
+ }
98
+
99
+ async reply(feedbackId: string, reply: Omit<FeedbackItem['replies'][0], 'id' | 'feedbackId'>) {
100
+ const item = await this.read(feedbackId)
101
+ item.replies.push({ ...reply, id: crypto.randomUUID(), feedbackId })
102
+ await this.write(feedbackId, item)
103
+ }
104
+
105
+ async resolve(feedbackId: string, resolution: Omit<FeedbackItem['resolutions'][0], 'feedbackId'>) {
106
+ const item = await this.read(feedbackId)
107
+ item.resolutions.push({ ...resolution, feedbackId })
108
+ await this.write(feedbackId, item)
109
+ }
110
+
111
+ async archive(feedbackId: string, userId: string) {
112
+ const item = await this.read(feedbackId)
113
+ item.archives.push({ feedbackId, archivedBy: { id: userId, name: '', role: '' }, timestamp: new Date().toISOString() })
114
+ await this.write(feedbackId, item)
115
+ }
116
+
117
+ async loadFeedback() {
118
+ await mkdir(DIR, { recursive: true })
119
+ const files = (await readdir(DIR)).filter((f) => f.endsWith('.json'))
120
+ return Promise.all(files.map(async (f) => JSON.parse(await readFile(join(DIR, f), 'utf-8')) as FeedbackItem))
121
+ }
122
+
123
+ private async read(id: string) { return JSON.parse(await readFile(join(DIR, `${id}.json`), 'utf-8')) as FeedbackItem }
124
+ private async write(id: string, item: FeedbackItem) { await writeFile(join(DIR, `${id}.json`), JSON.stringify(item, null, 2)) }
125
+ }
126
+ ```
127
+
128
+ ### Supabase
129
+
130
+ ```ts
131
+ import type { FeedtackAdapter, FeedbackItem, FeedtackFilter, FeedtackPayload } from 'feedtack'
132
+ import type { SupabaseClient } from '@supabase/supabase-js'
133
+
134
+ class SupabaseAdapter implements FeedtackAdapter {
135
+ constructor(private supabase: SupabaseClient) {}
136
+
137
+ async submit(payload: FeedtackPayload) {
138
+ await this.supabase.from('feedtack_submissions').insert({ id: payload.id, data: payload })
139
+ }
140
+
141
+ async reply(feedbackId: string, reply: Omit<FeedbackItem['replies'][0], 'id' | 'feedbackId'>) {
142
+ await this.supabase.from('feedtack_replies').insert({ feedback_id: feedbackId, ...reply })
143
+ }
144
+
145
+ async resolve(feedbackId: string, resolution: Omit<FeedbackItem['resolutions'][0], 'feedbackId'>) {
146
+ await this.supabase.from('feedtack_resolutions').insert({ feedback_id: feedbackId, ...resolution })
147
+ }
148
+
149
+ async archive(feedbackId: string, userId: string) {
150
+ await this.supabase.from('feedtack_archives').insert({ feedback_id: feedbackId, user_id: userId })
151
+ }
152
+
153
+ async loadFeedback(filter?: FeedtackFilter): Promise<FeedbackItem[]> {
154
+ let query = this.supabase.from('feedtack_submissions').select('*, feedtack_replies(*), feedtack_resolutions(*), feedtack_archives(*)')
155
+ if (filter?.pathname) query = query.eq('data->>page->>pathname', filter.pathname)
156
+ const { data } = await query
157
+ return (data ?? []).map((row) => ({
158
+ payload: row.data,
159
+ replies: row.feedtack_replies ?? [],
160
+ resolutions: row.feedtack_resolutions ?? [],
161
+ archives: row.feedtack_archives ?? [],
162
+ }))
163
+ }
164
+ }
165
+ ```
166
+
77
167
  ## The payload
78
168
 
79
169
  Every pin emits a versioned JSON payload:
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 };
@@ -171,7 +171,7 @@ function ThreadPanel({
171
171
  item.replies.map((r) => /* @__PURE__ */ jsxs2(
172
172
  "div",
173
173
  {
174
- style: { borderTop: "1px solid #f3f4f6", paddingTop: 8 },
174
+ style: { borderTop: "1px solid var(--ft-border)", paddingTop: 8 },
175
175
  children: [
176
176
  /* @__PURE__ */ jsx2("span", { style: { fontSize: 12, fontWeight: 600 }, children: r.author.name }),
177
177
  /* @__PURE__ */ jsx2("p", { style: { fontSize: 12 }, children: r.body })
@@ -190,7 +190,11 @@ function ThreadPanel({
190
190
  fontSize: 12,
191
191
  padding: 6,
192
192
  borderRadius: 6,
193
- border: "1px solid #e5e7eb",
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,21 +536,86 @@ 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";
537
- function usePinMode({ hotkey, onDeactivate, disabled }) {
600
+ import { useCallback as useCallback2, useEffect as useEffect3, useState } from "react";
601
+ function usePinMode({
602
+ hotkey,
603
+ onDeactivate,
604
+ disabled,
605
+ isModalOpen
606
+ }) {
538
607
  const [isActive, setIsActive] = useState(false);
539
608
  const [pendingPins, setPendingPins] = useState([]);
540
609
  const [selectedColor, setSelectedColor] = useState(PIN_PALETTE[0]);
541
610
  const [showForm, setShowForm] = useState(false);
542
- const activate = useCallback(() => setIsActive(true), []);
543
- const deactivate = useCallback(() => {
611
+ const activate = useCallback2(() => setIsActive(true), []);
612
+ const deactivate = useCallback2(() => {
544
613
  setIsActive(false);
545
614
  setPendingPins([]);
546
615
  setShowForm(false);
547
616
  onDeactivate?.();
548
617
  }, [onDeactivate]);
549
- useEffect2(() => {
618
+ useEffect3(() => {
550
619
  if (isActive) {
551
620
  document.documentElement.classList.add("feedtack-crosshair");
552
621
  } else {
@@ -554,7 +623,7 @@ function usePinMode({ hotkey, onDeactivate, disabled }) {
554
623
  }
555
624
  return () => document.documentElement.classList.remove("feedtack-crosshair");
556
625
  }, [isActive]);
557
- useEffect2(() => {
626
+ useEffect3(() => {
558
627
  if (disabled) return;
559
628
  const handler = (e) => {
560
629
  if (e.key === hotkey.toUpperCase() && e.shiftKey) {
@@ -563,7 +632,7 @@ function usePinMode({ hotkey, onDeactivate, disabled }) {
563
632
  if (e.key === "Escape") {
564
633
  deactivate();
565
634
  }
566
- if (isActive && (e.key === "ArrowLeft" || e.key === "ArrowRight")) {
635
+ if (isActive && !isModalOpen && !showForm && (e.key === "ArrowLeft" || e.key === "ArrowRight")) {
567
636
  e.preventDefault();
568
637
  setSelectedColor((prev) => {
569
638
  const idx = PIN_PALETTE.indexOf(prev);
@@ -574,32 +643,53 @@ function usePinMode({ hotkey, onDeactivate, disabled }) {
574
643
  };
575
644
  window.addEventListener("keydown", handler);
576
645
  return () => window.removeEventListener("keydown", handler);
577
- }, [hotkey, deactivate, isActive, disabled]);
578
- const handlePageClick = useCallback(
579
- (e) => {
580
- if (!isActive) return;
581
- const target = e.target;
646
+ }, [hotkey, deactivate, isActive, disabled, isModalOpen, showForm]);
647
+ const placePin = useCallback2(
648
+ (coords, target) => {
582
649
  if (target.closest("#feedtack-root, .feedtack-form, .feedtack-color-picker"))
583
650
  return;
584
- e.preventDefault();
585
- e.stopPropagation();
586
651
  setPendingPins((prev) => [
587
652
  ...prev,
588
653
  {
589
654
  color: selectedColor,
590
- ...getPinCoords(e),
655
+ ...getPinCoords(coords),
591
656
  target: getTargetMeta(target)
592
657
  }
593
658
  ]);
594
659
  setShowForm(true);
595
660
  },
596
- [isActive, selectedColor]
661
+ [selectedColor]
597
662
  );
598
- 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(() => {
599
685
  if (disabled) return;
600
686
  document.addEventListener("click", handlePageClick, true);
601
- return () => document.removeEventListener("click", handlePageClick, true);
602
- }, [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]);
603
693
  return {
604
694
  isActive,
605
695
  activate,
@@ -618,11 +708,16 @@ function useFeedtackState({
618
708
  hotkey,
619
709
  theme,
620
710
  onError,
621
- disabled
711
+ disabled,
712
+ onFlush,
713
+ flushIdleMs,
714
+ rescopeRoles
622
715
  }) {
623
716
  useFeedtackDom(theme, disabled);
624
- const [pathname, setPathname] = useState2(() => window.location.pathname);
625
- useEffect3(() => {
717
+ const [pathname, setPathname] = useState2(
718
+ () => typeof window === "undefined" ? "/" : window.location.pathname
719
+ );
720
+ useEffect4(() => {
626
721
  const update = () => setPathname(window.location.pathname);
627
722
  const origPush = history.pushState.bind(history);
628
723
  const origReplace = history.replaceState.bind(history);
@@ -649,7 +744,7 @@ function useFeedtackState({
649
744
  const [loading, setLoading] = useState2(true);
650
745
  const [openThreadId, setOpenThreadId] = useState2(null);
651
746
  const [replyBody, setReplyBody] = useState2("");
652
- const resetForm = useCallback2(() => {
747
+ const resetForm = useCallback3(() => {
653
748
  setComment("");
654
749
  setSentiment(null);
655
750
  setCommentError(false);
@@ -657,12 +752,20 @@ function useFeedtackState({
657
752
  const pinMode = usePinMode({
658
753
  hotkey,
659
754
  disabled,
755
+ isModalOpen: openThreadId !== null,
660
756
  onDeactivate: () => {
661
757
  resetForm();
662
758
  setOpenThreadId(null);
663
759
  }
664
760
  });
665
- useEffect3(() => {
761
+ const { clearFlushed } = useFeedtackFlush({
762
+ pathname,
763
+ feedbackItems,
764
+ onFlush,
765
+ flushIdleMs,
766
+ disabled
767
+ });
768
+ useEffect4(() => {
666
769
  setLoading(true);
667
770
  adapter.loadFeedback({ pathname }).then(setFeedbackItems).catch((err) => onError?.(err)).finally(() => setLoading(false));
668
771
  }, [adapter, onError, pathname]);
@@ -710,19 +813,26 @@ function useFeedtackState({
710
813
  body,
711
814
  timestamp: ts
712
815
  });
713
- updateItem(feedbackId, (item) => ({
714
- ...item,
715
- replies: [
716
- ...item.replies,
717
- {
718
- id: generateId(),
719
- feedbackId,
720
- author: currentUser,
721
- body,
722
- timestamp: ts
723
- }
724
- ]
725
- }));
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
+ });
726
836
  setReplyBody("");
727
837
  } catch (err) {
728
838
  onError?.(err);
@@ -762,6 +872,9 @@ function useFeedtackState({
762
872
  onError?.(err);
763
873
  }
764
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;
765
878
  return {
766
879
  ...pinMode,
767
880
  isPinModeActive: pinMode.isActive,
@@ -785,9 +898,9 @@ function useFeedtackState({
785
898
  handleReply,
786
899
  handleResolve,
787
900
  handleArchive,
788
- isArchivedForUser: (item) => item.archives.some((a) => a.archivedBy.id === currentUser.id),
789
- hasUnread: (item) => item.replies.length > 0,
790
- hasValidPins: (item) => Array.isArray(item.payload?.pins) && item.payload.pins.length > 0
901
+ isArchivedForUser,
902
+ hasUnread,
903
+ hasValidPins
791
904
  };
792
905
  }
793
906
 
@@ -804,7 +917,10 @@ function FeedtackProvider({
804
917
  sentimentLabels = {},
805
918
  onError,
806
919
  disabled = false,
807
- renderPinIcon
920
+ renderPinIcon,
921
+ onFlush,
922
+ flushIdleMs,
923
+ rescopeRoles
808
924
  }) {
809
925
  const state = useFeedtackState({
810
926
  adapter,
@@ -812,7 +928,10 @@ function FeedtackProvider({
812
928
  hotkey,
813
929
  theme,
814
930
  onError,
815
- disabled
931
+ disabled,
932
+ onFlush,
933
+ flushIdleMs,
934
+ rescopeRoles
816
935
  });
817
936
  const firstPin = state.pendingPins[0];
818
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.0",
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",