feedtack 0.0.2 → 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.
@@ -1,21 +1,58 @@
1
- // src/types/payload.ts
2
- var SCHEMA_VERSION = "1.0.0";
3
-
4
- // src/types/theme.ts
5
- function themeToCSS(theme) {
6
- const map = {};
7
- if (theme.primary) map["--ft-primary"] = theme.primary;
8
- if (theme.background) map["--ft-bg"] = theme.background;
9
- if (theme.surface) map["--ft-surface"] = theme.surface;
10
- if (theme.text) map["--ft-text"] = theme.text;
11
- if (theme.textMuted) map["--ft-text-muted"] = theme.textMuted;
12
- if (theme.border) map["--ft-border"] = theme.border;
13
- if (theme.radius) map["--ft-radius"] = theme.radius;
14
- if (theme.badge) map["--ft-badge"] = theme.badge;
15
- return map;
1
+ // src/capture/meta.ts
2
+ function getViewportMeta() {
3
+ return {
4
+ width: window.innerWidth,
5
+ height: window.innerHeight,
6
+ scrollX: window.scrollX,
7
+ scrollY: window.scrollY,
8
+ devicePixelRatio: window.devicePixelRatio
9
+ };
10
+ }
11
+ function getPageMeta() {
12
+ return {
13
+ url: window.location.href,
14
+ pathname: window.location.pathname,
15
+ title: document.title
16
+ };
17
+ }
18
+ function getDeviceMeta() {
19
+ return {
20
+ userAgent: navigator.userAgent,
21
+ platform: navigator.platform,
22
+ touchEnabled: navigator.maxTouchPoints > 0
23
+ };
24
+ }
25
+ function getPinCoords(event) {
26
+ const x = event.clientX + window.scrollX;
27
+ const y = event.clientY + window.scrollY;
28
+ const docWidth = document.documentElement.scrollWidth;
29
+ const docHeight = document.documentElement.scrollHeight;
30
+ return {
31
+ x,
32
+ y,
33
+ xPct: Number((x / docWidth * 100).toFixed(2)),
34
+ yPct: Number((y / docHeight * 100).toFixed(2))
35
+ };
16
36
  }
17
37
 
18
38
  // src/capture/target.ts
39
+ function getElementPath(element) {
40
+ const parts = [];
41
+ let current = element;
42
+ while (current && current !== document.body) {
43
+ const tag = current.tagName.toLowerCase();
44
+ const classes = Array.from(current.classList).join(".");
45
+ let part = classes ? `${tag}.${classes}` : tag;
46
+ if (current !== element && current.hasAttribute("data-testid")) {
47
+ part = `[data-testid="${current.getAttribute("data-testid")}"]`;
48
+ parts.unshift(part);
49
+ break;
50
+ }
51
+ parts.unshift(part);
52
+ current = current.parentElement;
53
+ }
54
+ return parts.join(" > ");
55
+ }
19
56
  function getCSSSelector(element) {
20
57
  const parts = [];
21
58
  let current = element;
@@ -59,6 +96,8 @@ function getTargetMeta(element) {
59
96
  return {
60
97
  selector,
61
98
  best_effort,
99
+ testId: testId ?? null,
100
+ elementPath: testId ? null : getElementPath(element),
62
101
  tagName: element.tagName,
63
102
  textContent: (element.textContent ?? "").trim().slice(0, 200),
64
103
  attributes: attrs,
@@ -71,50 +110,30 @@ function getTargetMeta(element) {
71
110
  };
72
111
  }
73
112
 
74
- // src/capture/meta.ts
75
- function getViewportMeta() {
76
- return {
77
- width: window.innerWidth,
78
- height: window.innerHeight,
79
- scrollX: window.scrollX,
80
- scrollY: window.scrollY,
81
- devicePixelRatio: window.devicePixelRatio
82
- };
83
- }
84
- function getPageMeta() {
85
- return {
86
- url: window.location.href,
87
- pathname: window.location.pathname,
88
- title: document.title
89
- };
90
- }
91
- function getDeviceMeta() {
92
- return {
93
- userAgent: navigator.userAgent,
94
- platform: navigator.platform,
95
- touchEnabled: navigator.maxTouchPoints > 0
96
- };
97
- }
98
- function getPinCoords(event) {
99
- const x = event.clientX + window.scrollX;
100
- const y = event.clientY + window.scrollY;
101
- const docWidth = document.documentElement.scrollWidth;
102
- const docHeight = document.documentElement.scrollHeight;
103
- return {
104
- x,
105
- y,
106
- xPct: Number((x / docWidth * 100).toFixed(2)),
107
- yPct: Number((y / docHeight * 100).toFixed(2))
108
- };
113
+ // src/types/payload.ts
114
+ var SCHEMA_VERSION = "1.0.0";
115
+
116
+ // src/types/theme.ts
117
+ function themeToCSS(theme) {
118
+ const map = {};
119
+ if (theme.primary) map["--ft-primary"] = theme.primary;
120
+ if (theme.background) map["--ft-bg"] = theme.background;
121
+ if (theme.surface) map["--ft-surface"] = theme.surface;
122
+ if (theme.text) map["--ft-text"] = theme.text;
123
+ if (theme.textMuted) map["--ft-text-muted"] = theme.textMuted;
124
+ if (theme.border) map["--ft-border"] = theme.border;
125
+ if (theme.radius) map["--ft-radius"] = theme.radius;
126
+ if (theme.badge) map["--ft-badge"] = theme.badge;
127
+ return map;
109
128
  }
110
129
 
111
130
  export {
112
- SCHEMA_VERSION,
113
- themeToCSS,
114
- getCSSSelector,
115
- getTargetMeta,
116
131
  getViewportMeta,
117
132
  getPageMeta,
118
133
  getDeviceMeta,
119
- getPinCoords
134
+ getPinCoords,
135
+ getCSSSelector,
136
+ getTargetMeta,
137
+ SCHEMA_VERSION,
138
+ themeToCSS
120
139
  };
package/dist/index.d.ts CHANGED
@@ -1,5 +1,5 @@
1
- import { F as FeedtackAdapter, a as FeedtackPayload, b as FeedtackReply, c as FeedtackResolution, d as FeedtackFilter, e as FeedbackItem, f as FeedtackPinTarget, g as FeedtackDeviceMeta, h as FeedtackPageMeta, i as FeedtackViewportMeta } from './theme-CHGvGcG5.js';
2
- export { j as FeedtackArchive, k as FeedtackBoundingRect, l as FeedtackPin, m as FeedtackSentiment, n as FeedtackTheme, o as FeedtackUser, S as SCHEMA_VERSION, t as themeToCSS } from './theme-CHGvGcG5.js';
1
+ import { F as FeedtackAdapter, a as FeedtackPayload, b as FeedtackReply, c as FeedtackResolution, d as FeedtackFilter, e as FeedbackItem, f as FeedtackDeviceMeta, g as FeedtackPageMeta, h as FeedtackViewportMeta, i as FeedtackPinTarget } from './theme-C_JZCVVA.js';
2
+ export { j as FeedtackArchive, k as FeedtackBoundingRect, l as FeedtackPin, m as FeedtackSentiment, n as FeedtackTheme, o as FeedtackUser, S as SCHEMA_VERSION, t as themeToCSS } from './theme-C_JZCVVA.js';
3
3
 
4
4
  /** Development adapter — logs all operations to the browser console */
5
5
  declare class ConsoleAdapter implements FeedtackAdapter {
@@ -10,6 +10,23 @@ declare class ConsoleAdapter implements FeedtackAdapter {
10
10
  loadFeedback(_filter?: FeedtackFilter): Promise<FeedbackItem[]>;
11
11
  }
12
12
 
13
+ interface LocalStorageAdapterConfig {
14
+ /** localStorage key. Default: 'feedtack' */
15
+ key?: string;
16
+ }
17
+ /** Zero-infrastructure adapter — persists feedback to localStorage */
18
+ declare class LocalStorageAdapter implements FeedtackAdapter {
19
+ private key;
20
+ constructor(config?: LocalStorageAdapterConfig);
21
+ private read;
22
+ private write;
23
+ submit(payload: FeedtackPayload): Promise<void>;
24
+ reply(feedbackId: string, reply: Omit<FeedtackReply, 'id' | 'feedbackId'>): Promise<void>;
25
+ resolve(feedbackId: string, resolution: Omit<FeedtackResolution, 'feedbackId'>): Promise<void>;
26
+ archive(feedbackId: string, userId: string): Promise<void>;
27
+ loadFeedback(filter?: FeedtackFilter): Promise<FeedbackItem[]>;
28
+ }
29
+
13
30
  interface WebhookAdapterConfig {
14
31
  /** URL to POST new feedback payloads to */
15
32
  submitUrl: string;
@@ -30,11 +47,6 @@ declare class WebhookAdapter implements FeedtackAdapter {
30
47
  loadFeedback(filter?: FeedtackFilter): Promise<FeedbackItem[]>;
31
48
  }
32
49
 
33
- /** Build shortest unique CSS selector for an element */
34
- declare function getCSSSelector(element: Element): string;
35
- /** Capture DOM target metadata at the clicked element */
36
- declare function getTargetMeta(element: Element): FeedtackPinTarget;
37
-
38
50
  declare function getViewportMeta(): FeedtackViewportMeta;
39
51
  declare function getPageMeta(): FeedtackPageMeta;
40
52
  declare function getDeviceMeta(): FeedtackDeviceMeta;
@@ -45,4 +57,9 @@ declare function getPinCoords(event: MouseEvent): {
45
57
  yPct: number;
46
58
  };
47
59
 
48
- export { ConsoleAdapter, FeedbackItem, FeedtackAdapter, FeedtackDeviceMeta, FeedtackFilter, FeedtackPageMeta, FeedtackPayload, FeedtackPinTarget, FeedtackReply, FeedtackResolution, FeedtackViewportMeta, WebhookAdapter, type WebhookAdapterConfig, getCSSSelector, getDeviceMeta, getPageMeta, getPinCoords, getTargetMeta, getViewportMeta };
60
+ /** Build shortest unique CSS selector for an element */
61
+ declare function getCSSSelector(element: Element): string;
62
+ /** Capture DOM target metadata at the clicked element */
63
+ declare function getTargetMeta(element: Element): FeedtackPinTarget;
64
+
65
+ export { ConsoleAdapter, FeedbackItem, FeedtackAdapter, FeedtackDeviceMeta, FeedtackFilter, FeedtackPageMeta, FeedtackPayload, FeedtackPinTarget, FeedtackReply, FeedtackResolution, FeedtackViewportMeta, LocalStorageAdapter, type LocalStorageAdapterConfig, WebhookAdapter, type WebhookAdapterConfig, getCSSSelector, getDeviceMeta, getPageMeta, getPinCoords, getTargetMeta, getViewportMeta };
package/dist/index.js CHANGED
@@ -7,7 +7,7 @@ import {
7
7
  getTargetMeta,
8
8
  getViewportMeta,
9
9
  themeToCSS
10
- } from "./chunk-NOFOQJHM.js";
10
+ } from "./chunk-XVWG3PLK.js";
11
11
 
12
12
  // src/adapters/ConsoleAdapter.ts
13
13
  var ConsoleAdapter = class {
@@ -29,6 +29,70 @@ var ConsoleAdapter = class {
29
29
  }
30
30
  };
31
31
 
32
+ // src/adapters/LocalStorageAdapter.ts
33
+ var LocalStorageAdapter = class {
34
+ constructor(config = {}) {
35
+ this.key = config.key ?? "feedtack";
36
+ }
37
+ read() {
38
+ try {
39
+ const raw = localStorage.getItem(this.key);
40
+ return raw ? JSON.parse(raw) : [];
41
+ } catch {
42
+ return [];
43
+ }
44
+ }
45
+ write(items) {
46
+ localStorage.setItem(this.key, JSON.stringify(items));
47
+ }
48
+ async submit(payload) {
49
+ const items = this.read();
50
+ items.push({ payload, replies: [], resolutions: [], archives: [] });
51
+ this.write(items);
52
+ }
53
+ async reply(feedbackId, reply) {
54
+ const items = this.read();
55
+ const item = items.find((i) => i.payload.id === feedbackId);
56
+ if (!item) return;
57
+ item.replies.push({
58
+ id: `r_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 7)}`,
59
+ feedbackId,
60
+ ...reply
61
+ });
62
+ this.write(items);
63
+ }
64
+ async resolve(feedbackId, resolution) {
65
+ const items = this.read();
66
+ const item = items.find((i) => i.payload.id === feedbackId);
67
+ if (!item) return;
68
+ item.resolutions.push({ feedbackId, ...resolution });
69
+ this.write(items);
70
+ }
71
+ async archive(feedbackId, userId) {
72
+ const items = this.read();
73
+ const item = items.find((i) => i.payload.id === feedbackId);
74
+ if (!item) return;
75
+ item.archives.push({
76
+ feedbackId,
77
+ archivedBy: { id: userId, name: "", role: "" },
78
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
79
+ });
80
+ this.write(items);
81
+ }
82
+ async loadFeedback(filter) {
83
+ const items = this.read();
84
+ if (!filter) return items;
85
+ return items.filter((item) => {
86
+ if (filter.pathname && item.payload.page.pathname !== filter.pathname)
87
+ return false;
88
+ if (filter.url && item.payload.page.url !== filter.url) return false;
89
+ if (filter.userId && item.payload.submittedBy.id !== filter.userId)
90
+ return false;
91
+ return true;
92
+ });
93
+ }
94
+ };
95
+
32
96
  // src/adapters/WebhookAdapter.ts
33
97
  var WebhookAdapter = class {
34
98
  constructor(config) {
@@ -70,6 +134,7 @@ var WebhookAdapter = class {
70
134
  };
71
135
  export {
72
136
  ConsoleAdapter,
137
+ LocalStorageAdapter,
73
138
  SCHEMA_VERSION,
74
139
  WebhookAdapter,
75
140
  getCSSSelector,
@@ -1,34 +1,30 @@
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 } from '../theme-CHGvGcG5.js';
3
+ import { F as FeedtackAdapter, o as FeedtackUser, n as FeedtackTheme } from '../theme-C_JZCVVA.js';
4
4
 
5
5
  interface FeedtackClasses {
6
- /** Class added to the activation button */
7
6
  button?: string;
8
- /** Class added to the comment form panel */
9
7
  form?: string;
10
- /** Class added to the thread/reply panel */
11
8
  thread?: string;
12
- /** Class added to the color picker row */
13
9
  colorPicker?: string;
14
- /** Class added to each pin marker */
15
10
  pinMarker?: string;
16
11
  }
12
+ interface FeedtackSentimentLabels {
13
+ satisfied?: React.ReactNode;
14
+ dissatisfied?: React.ReactNode;
15
+ }
17
16
  interface FeedtackProviderProps {
18
17
  children: React.ReactNode;
19
18
  adapter: FeedtackAdapter;
20
19
  currentUser: FeedtackUser;
21
- /** Keyboard shortcut to toggle pin mode. Default: 'p' (Shift+P) */
22
20
  hotkey?: string;
23
- /** Only show the activation button for users whose role is in this list */
24
21
  adminOnly?: boolean;
25
- /** CSS token overrides for brand alignment */
26
22
  theme?: FeedtackTheme;
27
- /** Additional class names for individual feedtack elements */
28
23
  classes?: FeedtackClasses;
24
+ sentimentLabels?: FeedtackSentimentLabels;
29
25
  onError?: (err: Error) => void;
30
26
  }
31
- declare function FeedtackProvider({ children, adapter, currentUser, hotkey, adminOnly, theme, classes, onError }: FeedtackProviderProps): react_jsx_runtime.JSX.Element;
27
+ declare function FeedtackProvider({ children, adapter, currentUser, hotkey, adminOnly, theme, classes, sentimentLabels, onError, }: FeedtackProviderProps): react_jsx_runtime.JSX.Element;
32
28
 
33
29
  interface FeedtackContextValue {
34
30
  activatePinMode: () => void;
@@ -39,4 +35,4 @@ interface FeedtackContextValue {
39
35
  /** Hook for host app to programmatically control feedtack */
40
36
  declare function useFeedtack(): FeedtackContextValue;
41
37
 
42
- export { type FeedtackClasses, FeedtackProvider, type FeedtackProviderProps, useFeedtack };
38
+ export { type FeedtackClasses, FeedtackProvider, type FeedtackProviderProps, type FeedtackSentimentLabels, useFeedtack };
@@ -6,20 +6,157 @@ import {
6
6
  getTargetMeta,
7
7
  getViewportMeta,
8
8
  themeToCSS
9
- } from "../chunk-NOFOQJHM.js";
9
+ } from "../chunk-XVWG3PLK.js";
10
10
 
11
- // src/react/FeedtackProvider.tsx
12
- import { useCallback, useEffect, useRef, useState } from "react";
11
+ // src/ui/colors.ts
12
+ var PIN_PALETTE = [
13
+ "#ef4444",
14
+ // red
15
+ "#3b82f6",
16
+ // blue
17
+ "#22c55e",
18
+ // green
19
+ "#f59e0b",
20
+ // amber
21
+ "#a855f7",
22
+ // purple
23
+ "#ec4899"
24
+ // pink
25
+ ];
13
26
 
14
27
  // src/react/context.ts
15
28
  import { createContext, useContext } from "react";
16
29
  var FeedtackContext = createContext(null);
17
30
  function useFeedtackContext() {
18
31
  const ctx = useContext(FeedtackContext);
19
- if (!ctx) throw new Error("useFeedtack must be used inside <FeedtackProvider>");
32
+ if (!ctx)
33
+ throw new Error("useFeedtack must be used inside <FeedtackProvider>");
20
34
  return ctx;
21
35
  }
22
36
 
37
+ // src/react/utils.ts
38
+ function generateId() {
39
+ return `ft_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 7)}`;
40
+ }
41
+ function getAnchoredPosition(x, y) {
42
+ const FORM_HEIGHT = 220;
43
+ const EDGE = 300;
44
+ const vw = window.innerWidth;
45
+ const vh = window.innerHeight;
46
+ const clientX = x - window.scrollX;
47
+ const clientY = y - window.scrollY;
48
+ const left = clientX > vw - EDGE ? void 0 : clientX + 16;
49
+ const right = clientX > vw - EDGE ? vw - clientX + 16 : void 0;
50
+ const top = clientY > vh - EDGE ? void 0 : clientY + 16;
51
+ const bottom = clientY > vh - EDGE ? vh - clientY + FORM_HEIGHT : void 0;
52
+ return { left, right, top, bottom };
53
+ }
54
+ function cx(...parts) {
55
+ return parts.filter(Boolean).join(" ");
56
+ }
57
+
58
+ // src/react/ThreadPanel.tsx
59
+ import { jsx, jsxs } from "react/jsx-runtime";
60
+ function ThreadPanel({
61
+ item,
62
+ replyBody,
63
+ onReplyBodyChange,
64
+ onReply,
65
+ onResolve,
66
+ onArchive,
67
+ onClose,
68
+ className
69
+ }) {
70
+ const pin = item.payload.pins[0];
71
+ const pos = getAnchoredPosition(pin.x, pin.y);
72
+ return /* @__PURE__ */ jsxs(
73
+ "div",
74
+ {
75
+ className: cx("feedtack-thread", className),
76
+ style: { position: "fixed", ...pos },
77
+ children: [
78
+ /* @__PURE__ */ jsx("strong", { style: { fontSize: 13 }, children: item.payload.submittedBy.name }),
79
+ /* @__PURE__ */ jsx("p", { style: { fontSize: 13 }, children: item.payload.comment }),
80
+ item.replies.map((r) => /* @__PURE__ */ jsxs(
81
+ "div",
82
+ {
83
+ style: { borderTop: "1px solid #f3f4f6", paddingTop: 8 },
84
+ children: [
85
+ /* @__PURE__ */ jsx("span", { style: { fontSize: 12, fontWeight: 600 }, children: r.author.name }),
86
+ /* @__PURE__ */ jsx("p", { style: { fontSize: 12 }, children: r.body })
87
+ ]
88
+ },
89
+ r.id
90
+ )),
91
+ /* @__PURE__ */ jsx(
92
+ "textarea",
93
+ {
94
+ placeholder: "Reply\u2026",
95
+ value: replyBody,
96
+ onChange: (e) => onReplyBodyChange(e.target.value),
97
+ style: {
98
+ width: "100%",
99
+ fontSize: 12,
100
+ padding: 6,
101
+ borderRadius: 6,
102
+ border: "1px solid #e5e7eb",
103
+ marginTop: 4
104
+ }
105
+ }
106
+ ),
107
+ /* @__PURE__ */ jsxs("div", { style: { display: "flex", gap: 6, flexWrap: "wrap" }, children: [
108
+ /* @__PURE__ */ jsx(
109
+ "button",
110
+ {
111
+ type: "button",
112
+ className: "feedtack-btn-submit",
113
+ style: { fontSize: 12, padding: "4px 10px" },
114
+ onClick: onReply,
115
+ children: "Reply"
116
+ }
117
+ ),
118
+ /* @__PURE__ */ jsx(
119
+ "button",
120
+ {
121
+ type: "button",
122
+ className: "feedtack-btn-cancel",
123
+ style: { fontSize: 12 },
124
+ onClick: onResolve,
125
+ children: "Mark Resolved"
126
+ }
127
+ ),
128
+ /* @__PURE__ */ jsx(
129
+ "button",
130
+ {
131
+ type: "button",
132
+ className: "feedtack-btn-cancel",
133
+ style: { fontSize: 12 },
134
+ onClick: onArchive,
135
+ children: "Archive"
136
+ }
137
+ ),
138
+ /* @__PURE__ */ jsx(
139
+ "button",
140
+ {
141
+ type: "button",
142
+ className: "feedtack-btn-cancel",
143
+ style: { fontSize: 12 },
144
+ onClick: onClose,
145
+ children: "Close"
146
+ }
147
+ )
148
+ ] })
149
+ ]
150
+ }
151
+ );
152
+ }
153
+
154
+ // src/react/useFeedtackState.ts
155
+ import { useCallback as useCallback2, useEffect as useEffect3, useState as useState2 } from "react";
156
+
157
+ // src/react/useFeedtackDom.ts
158
+ import { useEffect, useRef } from "react";
159
+
23
160
  // src/ui/styles.ts
24
161
  var FEEDTACK_DEFAULT_TOKENS = `
25
162
  #feedtack-root {
@@ -82,7 +219,8 @@ var FEEDTACK_STYLES = `
82
219
  width: 24px;
83
220
  height: 24px;
84
221
  border-radius: 50% 50% 50% 0;
85
- transform: rotate(-45deg) translate(-50%, -50%);
222
+ transform: translate(-50%, -100%) rotate(-45deg);
223
+ transform-origin: bottom center;
86
224
  border: 2px solid rgba(255,255,255,0.8);
87
225
  box-shadow: 0 2px 6px rgba(0,0,0,0.3);
88
226
  cursor: pointer;
@@ -104,7 +242,7 @@ var FEEDTACK_STYLES = `
104
242
  display: flex;
105
243
  gap: 6px;
106
244
  padding: 8px;
107
- background: var(--ft-bg);
245
+ background: var(--ft-bg) !important;
108
246
  border-radius: var(--ft-radius);
109
247
  box-shadow: 0 2px 8px rgba(0,0,0,0.15);
110
248
  position: fixed;
@@ -130,7 +268,7 @@ var FEEDTACK_STYLES = `
130
268
  .feedtack-form {
131
269
  position: absolute;
132
270
  z-index: 2147483642;
133
- background: var(--ft-bg);
271
+ background: var(--ft-bg) !important;
134
272
  border-radius: calc(var(--ft-radius) + 2px);
135
273
  box-shadow: 0 4px 20px rgba(0,0,0,0.18);
136
274
  padding: 16px;
@@ -224,7 +362,7 @@ var FEEDTACK_STYLES = `
224
362
  .feedtack-thread {
225
363
  position: absolute;
226
364
  z-index: 2147483642;
227
- background: var(--ft-bg);
365
+ background: var(--ft-bg) !important;
228
366
  border-radius: calc(var(--ft-radius) + 2px);
229
367
  box-shadow: 0 4px 20px rgba(0,0,0,0.18);
230
368
  padding: 16px;
@@ -246,54 +384,8 @@ var FEEDTACK_STYLES = `
246
384
  }
247
385
  `;
248
386
 
249
- // src/ui/colors.ts
250
- var PIN_PALETTE = [
251
- "#ef4444",
252
- // red
253
- "#3b82f6",
254
- // blue
255
- "#22c55e",
256
- // green
257
- "#f59e0b",
258
- // amber
259
- "#a855f7",
260
- // purple
261
- "#ec4899"
262
- // pink
263
- ];
264
-
265
- // src/react/FeedtackProvider.tsx
266
- import { jsx, jsxs } from "react/jsx-runtime";
267
- function generateId() {
268
- return `ft_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 7)}`;
269
- }
270
- function getAnchoredPosition(x, y) {
271
- const FORM_WIDTH = 290;
272
- const FORM_HEIGHT = 220;
273
- const EDGE = 300;
274
- const vw = window.innerWidth;
275
- const vh = window.innerHeight;
276
- const clientX = x - window.scrollX;
277
- const clientY = y - window.scrollY;
278
- const left = clientX > vw - EDGE ? void 0 : clientX + 16;
279
- const right = clientX > vw - EDGE ? vw - clientX + 16 : void 0;
280
- const top = clientY > vh - EDGE ? void 0 : clientY + 16;
281
- const bottom = clientY > vh - EDGE ? vh - clientY + FORM_HEIGHT : void 0;
282
- return { left, right, top, bottom };
283
- }
284
- function FeedtackProvider({ children, adapter, currentUser, hotkey = "p", adminOnly = false, theme, classes = {}, onError }) {
285
- const [isPinModeActive, setIsPinModeActive] = useState(false);
286
- const [pendingPins, setPendingPins] = useState([]);
287
- const [selectedColor, setSelectedColor] = useState(PIN_PALETTE[0]);
288
- const [showForm, setShowForm] = useState(false);
289
- const [comment, setComment] = useState("");
290
- const [sentiment, setSentiment] = useState(null);
291
- const [commentError, setCommentError] = useState(false);
292
- const [submitting, setSubmitting] = useState(false);
293
- const [feedbackItems, setFeedbackItems] = useState([]);
294
- const [loading, setLoading] = useState(true);
295
- const [openThreadId, setOpenThreadId] = useState(null);
296
- const [replyBody, setReplyBody] = useState("");
387
+ // src/react/useFeedtackDom.ts
388
+ function useFeedtackDom(theme) {
297
389
  const rootRef = useRef(null);
298
390
  useEffect(() => {
299
391
  if (document.getElementById("feedtack-styles")) return;
@@ -318,61 +410,126 @@ function FeedtackProvider({ children, adapter, currentUser, hotkey = "p", adminO
318
410
  const root = document.getElementById("feedtack-root");
319
411
  if (!root || !theme) return;
320
412
  const tokens = themeToCSS(theme);
321
- Object.entries(tokens).forEach(([k, v]) => root.style.setProperty(k, v));
413
+ for (const [k, v] of Object.entries(tokens)) {
414
+ root.style.setProperty(k, v);
415
+ }
322
416
  }, [theme]);
323
- useEffect(() => {
324
- setLoading(true);
325
- adapter.loadFeedback({ pathname: window.location.pathname }).then(setFeedbackItems).catch((err) => onError?.(err)).finally(() => setLoading(false));
326
- }, [adapter, onError]);
327
- const activatePinMode = useCallback(() => setIsPinModeActive(true), []);
328
- const deactivatePinMode = useCallback(() => {
329
- setIsPinModeActive(false);
417
+ return rootRef;
418
+ }
419
+
420
+ // src/react/usePinMode.ts
421
+ import { useCallback, useEffect as useEffect2, useState } from "react";
422
+ function usePinMode({ hotkey, onDeactivate }) {
423
+ const [isActive, setIsActive] = useState(false);
424
+ const [pendingPins, setPendingPins] = useState([]);
425
+ const [selectedColor, setSelectedColor] = useState(PIN_PALETTE[0]);
426
+ const [showForm, setShowForm] = useState(false);
427
+ const activate = useCallback(() => setIsActive(true), []);
428
+ const deactivate = useCallback(() => {
429
+ setIsActive(false);
330
430
  setPendingPins([]);
331
431
  setShowForm(false);
332
- setComment("");
333
- setSentiment(null);
334
- setCommentError(false);
335
- }, []);
336
- useEffect(() => {
337
- if (isPinModeActive) {
432
+ onDeactivate?.();
433
+ }, [onDeactivate]);
434
+ useEffect2(() => {
435
+ if (isActive) {
338
436
  document.documentElement.classList.add("feedtack-crosshair");
339
437
  } else {
340
438
  document.documentElement.classList.remove("feedtack-crosshair");
341
439
  }
342
440
  return () => document.documentElement.classList.remove("feedtack-crosshair");
343
- }, [isPinModeActive]);
344
- useEffect(() => {
441
+ }, [isActive]);
442
+ useEffect2(() => {
345
443
  const handler = (e) => {
346
444
  if (e.key === hotkey.toUpperCase() && e.shiftKey) {
347
- setIsPinModeActive((prev) => !prev);
445
+ setIsActive((prev) => !prev);
348
446
  }
349
447
  if (e.key === "Escape") {
350
- deactivatePinMode();
351
- setOpenThreadId(null);
448
+ deactivate();
449
+ }
450
+ if (isActive && (e.key === "ArrowLeft" || e.key === "ArrowRight")) {
451
+ e.preventDefault();
452
+ setSelectedColor((prev) => {
453
+ const idx = PIN_PALETTE.indexOf(prev);
454
+ const dir = e.key === "ArrowRight" ? 1 : -1;
455
+ return PIN_PALETTE[(idx + dir + PIN_PALETTE.length) % PIN_PALETTE.length];
456
+ });
352
457
  }
353
458
  };
354
459
  window.addEventListener("keydown", handler);
355
460
  return () => window.removeEventListener("keydown", handler);
356
- }, [hotkey, deactivatePinMode]);
357
- const handlePageClick = useCallback((e) => {
358
- if (!isPinModeActive) return;
359
- const target = e.target;
360
- if (target.closest("#feedtack-root") || target.closest(".feedtack-form") || target.closest(".feedtack-color-picker")) return;
361
- e.preventDefault();
362
- e.stopPropagation();
363
- const coords = getPinCoords(e);
364
- const targetMeta = getTargetMeta(target);
365
- setPendingPins((prev) => [...prev, {
366
- color: selectedColor,
367
- ...coords,
368
- target: targetMeta
369
- }]);
370
- setShowForm(true);
371
- }, [isPinModeActive, selectedColor]);
372
- useEffect(() => {
461
+ }, [hotkey, deactivate, isActive]);
462
+ const handlePageClick = useCallback(
463
+ (e) => {
464
+ if (!isActive) return;
465
+ const target = e.target;
466
+ if (target.closest("#feedtack-root, .feedtack-form, .feedtack-color-picker"))
467
+ return;
468
+ e.preventDefault();
469
+ e.stopPropagation();
470
+ setPendingPins((prev) => [
471
+ ...prev,
472
+ {
473
+ color: selectedColor,
474
+ ...getPinCoords(e),
475
+ target: getTargetMeta(target)
476
+ }
477
+ ]);
478
+ setShowForm(true);
479
+ },
480
+ [isActive, selectedColor]
481
+ );
482
+ useEffect2(() => {
373
483
  document.addEventListener("click", handlePageClick, true);
374
484
  return () => document.removeEventListener("click", handlePageClick, true);
375
485
  }, [handlePageClick]);
486
+ return {
487
+ isActive,
488
+ activate,
489
+ deactivate,
490
+ pendingPins,
491
+ selectedColor,
492
+ setSelectedColor,
493
+ showForm
494
+ };
495
+ }
496
+
497
+ // src/react/useFeedtackState.ts
498
+ function useFeedtackState({
499
+ adapter,
500
+ currentUser,
501
+ hotkey,
502
+ theme,
503
+ onError
504
+ }) {
505
+ useFeedtackDom(theme);
506
+ const [comment, setComment] = useState2("");
507
+ const [sentiment, setSentiment] = useState2(null);
508
+ const [commentError, setCommentError] = useState2(false);
509
+ const [submitting, setSubmitting] = useState2(false);
510
+ const [feedbackItems, setFeedbackItems] = useState2([]);
511
+ const [loading, setLoading] = useState2(true);
512
+ const [openThreadId, setOpenThreadId] = useState2(null);
513
+ const [replyBody, setReplyBody] = useState2("");
514
+ const resetForm = useCallback2(() => {
515
+ setComment("");
516
+ setSentiment(null);
517
+ setCommentError(false);
518
+ }, []);
519
+ const pinMode = usePinMode({
520
+ hotkey,
521
+ onDeactivate: () => {
522
+ resetForm();
523
+ setOpenThreadId(null);
524
+ }
525
+ });
526
+ useEffect3(() => {
527
+ setLoading(true);
528
+ adapter.loadFeedback({ pathname: window.location.pathname }).then(setFeedbackItems).catch((err) => onError?.(err)).finally(() => setLoading(false));
529
+ }, [adapter, onError]);
530
+ const updateItem = (id, fn) => setFeedbackItems(
531
+ (prev) => prev.map((i) => i.payload.id === id ? fn(i) : i)
532
+ );
376
533
  const handleSubmit = async () => {
377
534
  if (!comment.trim()) {
378
535
  setCommentError(true);
@@ -386,15 +543,18 @@ function FeedtackProvider({ children, adapter, currentUser, hotkey = "p", adminO
386
543
  submittedBy: currentUser,
387
544
  comment: comment.trim(),
388
545
  sentiment,
389
- pins: pendingPins.map((p, i) => ({ ...p, index: i + 1 })),
546
+ pins: pinMode.pendingPins.map((p, i) => ({ ...p, index: i + 1 })),
390
547
  page: getPageMeta(),
391
548
  viewport: getViewportMeta(),
392
549
  device: getDeviceMeta()
393
550
  };
394
551
  try {
395
552
  await adapter.submit(payload);
396
- setFeedbackItems((prev) => [...prev, { payload, replies: [], resolutions: [], archives: [] }]);
397
- deactivatePinMode();
553
+ setFeedbackItems((prev) => [
554
+ ...prev,
555
+ { payload, replies: [], resolutions: [], archives: [] }
556
+ ]);
557
+ pinMode.deactivate();
398
558
  } catch (err) {
399
559
  onError?.(err);
400
560
  } finally {
@@ -403,173 +563,279 @@ function FeedtackProvider({ children, adapter, currentUser, hotkey = "p", adminO
403
563
  };
404
564
  const handleReply = async (feedbackId) => {
405
565
  if (!replyBody.trim()) return;
566
+ const ts = (/* @__PURE__ */ new Date()).toISOString();
567
+ const body = replyBody.trim();
406
568
  try {
407
569
  await adapter.reply(feedbackId, {
408
570
  author: currentUser,
409
- body: replyBody.trim(),
410
- timestamp: (/* @__PURE__ */ new Date()).toISOString()
571
+ body,
572
+ timestamp: ts
411
573
  });
412
- setFeedbackItems((prev) => prev.map(
413
- (item) => item.payload.id === feedbackId ? { ...item, replies: [...item.replies, { id: generateId(), feedbackId, author: currentUser, body: replyBody.trim(), timestamp: (/* @__PURE__ */ new Date()).toISOString() }] } : item
414
- ));
574
+ updateItem(feedbackId, (item) => ({
575
+ ...item,
576
+ replies: [
577
+ ...item.replies,
578
+ {
579
+ id: generateId(),
580
+ feedbackId,
581
+ author: currentUser,
582
+ body,
583
+ timestamp: ts
584
+ }
585
+ ]
586
+ }));
415
587
  setReplyBody("");
416
588
  } catch (err) {
417
589
  onError?.(err);
418
590
  }
419
591
  };
420
592
  const handleResolve = async (feedbackId) => {
593
+ const ts = (/* @__PURE__ */ new Date()).toISOString();
421
594
  try {
422
- await adapter.resolve(feedbackId, { resolvedBy: currentUser, timestamp: (/* @__PURE__ */ new Date()).toISOString() });
423
- setFeedbackItems((prev) => prev.map(
424
- (item) => item.payload.id === feedbackId ? { ...item, resolutions: [...item.resolutions, { feedbackId, resolvedBy: currentUser, timestamp: (/* @__PURE__ */ new Date()).toISOString() }] } : item
425
- ));
595
+ await adapter.resolve(feedbackId, {
596
+ resolvedBy: currentUser,
597
+ timestamp: ts
598
+ });
599
+ updateItem(feedbackId, (item) => ({
600
+ ...item,
601
+ resolutions: [
602
+ ...item.resolutions,
603
+ { feedbackId, resolvedBy: currentUser, timestamp: ts }
604
+ ]
605
+ }));
426
606
  } catch (err) {
427
607
  onError?.(err);
428
608
  }
429
609
  };
430
610
  const handleArchive = async (feedbackId) => {
611
+ const ts = (/* @__PURE__ */ new Date()).toISOString();
431
612
  try {
432
613
  await adapter.archive(feedbackId, currentUser.id);
433
- setFeedbackItems((prev) => prev.map(
434
- (item) => item.payload.id === feedbackId ? { ...item, archives: [...item.archives, { feedbackId, archivedBy: currentUser, timestamp: (/* @__PURE__ */ new Date()).toISOString() }] } : item
435
- ));
614
+ updateItem(feedbackId, (item) => ({
615
+ ...item,
616
+ archives: [
617
+ ...item.archives,
618
+ { feedbackId, archivedBy: currentUser, timestamp: ts }
619
+ ]
620
+ }));
436
621
  setOpenThreadId(null);
437
622
  } catch (err) {
438
623
  onError?.(err);
439
624
  }
440
625
  };
441
- const isArchivedForUser = (item) => item.archives.some((a) => a.archivedBy.id === currentUser.id);
442
- const hasUnread = (item) => item.replies.length > 0;
443
- const firstPin = pendingPins[0];
626
+ return {
627
+ ...pinMode,
628
+ isPinModeActive: pinMode.isActive,
629
+ activatePinMode: pinMode.activate,
630
+ deactivatePinMode: pinMode.deactivate,
631
+ comment,
632
+ setComment,
633
+ sentiment,
634
+ setSentiment,
635
+ commentError,
636
+ setCommentError,
637
+ submitting,
638
+ feedbackItems,
639
+ loading,
640
+ openThreadId,
641
+ setOpenThreadId,
642
+ replyBody,
643
+ setReplyBody,
644
+ handleSubmit,
645
+ handleReply,
646
+ handleResolve,
647
+ handleArchive,
648
+ isArchivedForUser: (item) => item.archives.some((a) => a.archivedBy.id === currentUser.id),
649
+ hasUnread: (item) => item.replies.length > 0
650
+ };
651
+ }
652
+
653
+ // src/react/FeedtackProvider.tsx
654
+ import { jsx as jsx2, jsxs as jsxs2 } from "react/jsx-runtime";
655
+ function FeedtackProvider({
656
+ children,
657
+ adapter,
658
+ currentUser,
659
+ hotkey = "p",
660
+ adminOnly = false,
661
+ theme,
662
+ classes = {},
663
+ sentimentLabels = {},
664
+ onError
665
+ }) {
666
+ const state = useFeedtackState({
667
+ adapter,
668
+ currentUser,
669
+ hotkey,
670
+ theme,
671
+ onError
672
+ });
673
+ const firstPin = state.pendingPins[0];
444
674
  const formPos = firstPin ? getAnchoredPosition(firstPin.x, firstPin.y) : {};
445
675
  const showButton = !adminOnly || currentUser.role === "admin";
446
- return /* @__PURE__ */ jsxs(FeedtackContext.Provider, { value: { activatePinMode, deactivatePinMode, isPinModeActive }, children: [
447
- children,
448
- showButton && /* @__PURE__ */ jsxs(
449
- "button",
450
- {
451
- className: `feedtack-btn${isPinModeActive ? " active" : ""}${classes.button ? ` ${classes.button}` : ""}`,
452
- onClick: () => isPinModeActive ? deactivatePinMode() : activatePinMode(),
453
- title: "Toggle feedback pin mode",
454
- children: [
455
- "Drop Pin [Shift+",
456
- hotkey.toUpperCase(),
457
- "]"
458
- ]
459
- }
460
- ),
461
- isPinModeActive && /* @__PURE__ */ jsx("div", { className: `feedtack-color-picker${classes.colorPicker ? ` ${classes.colorPicker}` : ""}`, children: PIN_PALETTE.map((color) => /* @__PURE__ */ jsx(
462
- "button",
463
- {
464
- className: `feedtack-color-swatch${selectedColor === color ? " selected" : ""}`,
465
- style: { background: color },
466
- onClick: () => setSelectedColor(color),
467
- title: color
676
+ const openItem = state.openThreadId ? state.feedbackItems.find((i) => i.payload.id === state.openThreadId) : null;
677
+ return /* @__PURE__ */ jsxs2(
678
+ FeedtackContext.Provider,
679
+ {
680
+ value: {
681
+ activatePinMode: state.activatePinMode,
682
+ deactivatePinMode: state.deactivatePinMode,
683
+ isPinModeActive: state.isPinModeActive
468
684
  },
469
- color
470
- )) }),
471
- pendingPins.map((pin, i) => /* @__PURE__ */ jsx(
472
- "div",
473
- {
474
- className: `feedtack-pin-marker${classes.pinMarker ? ` ${classes.pinMarker}` : ""}`,
475
- style: {
476
- background: pin.color,
477
- left: pin.x,
478
- top: pin.y,
479
- position: "absolute"
480
- }
481
- },
482
- i
483
- )),
484
- showForm && /* @__PURE__ */ jsxs("div", { className: `feedtack-form${classes.form ? ` ${classes.form}` : ""}`, style: { position: "fixed", ...formPos }, children: [
485
- /* @__PURE__ */ jsx(
486
- "textarea",
487
- {
488
- className: commentError ? "error" : "",
489
- placeholder: "What's the issue? (required)",
490
- value: comment,
491
- onChange: (e) => {
492
- setComment(e.target.value);
493
- setCommentError(false);
494
- },
495
- autoFocus: true
496
- }
497
- ),
498
- commentError && /* @__PURE__ */ jsx("span", { className: "feedtack-error-msg", children: "Comment is required" }),
499
- /* @__PURE__ */ jsxs("div", { className: "feedtack-sentiment", children: [
500
- /* @__PURE__ */ jsx(
685
+ children: [
686
+ children,
687
+ showButton && /* @__PURE__ */ jsxs2(
501
688
  "button",
502
689
  {
503
- className: sentiment === "satisfied" ? "selected" : "",
504
- onClick: () => setSentiment(sentiment === "satisfied" ? null : "satisfied"),
505
- children: "\u{1F60A} Satisfied"
690
+ type: "button",
691
+ className: cx(
692
+ "feedtack-btn",
693
+ state.isPinModeActive && "active",
694
+ classes.button
695
+ ),
696
+ onClick: () => state.isPinModeActive ? state.deactivatePinMode() : state.activatePinMode(),
697
+ title: "Toggle feedback pin mode",
698
+ children: [
699
+ "Drop Pin [Shift+",
700
+ hotkey.toUpperCase(),
701
+ "]"
702
+ ]
506
703
  }
507
704
  ),
508
- /* @__PURE__ */ jsx(
705
+ state.isPinModeActive && /* @__PURE__ */ jsx2("div", { className: cx("feedtack-color-picker", classes.colorPicker), children: PIN_PALETTE.map((color) => /* @__PURE__ */ jsx2(
509
706
  "button",
510
707
  {
511
- className: sentiment === "dissatisfied" ? "selected" : "",
512
- onClick: () => setSentiment(sentiment === "dissatisfied" ? null : "dissatisfied"),
513
- children: "\u{1F61E} Dissatisfied"
514
- }
515
- )
516
- ] }),
517
- /* @__PURE__ */ jsxs("div", { className: "feedtack-form-actions", children: [
518
- /* @__PURE__ */ jsx("button", { className: "feedtack-btn-cancel", onClick: deactivatePinMode, children: "Cancel" }),
519
- /* @__PURE__ */ jsx("button", { className: "feedtack-btn-submit", onClick: handleSubmit, disabled: submitting, children: submitting ? "Sending\u2026" : "Submit" })
520
- ] })
521
- ] }),
522
- !loading && feedbackItems.filter((item) => !isArchivedForUser(item)).map((item) => {
523
- const firstItemPin = item.payload.pins[0];
524
- const unread = hasUnread(item);
525
- return /* @__PURE__ */ jsx(
526
- "div",
527
- {
528
- className: `feedtack-pin-marker${classes.pinMarker ? ` ${classes.pinMarker}` : ""}`,
529
- style: {
530
- background: firstItemPin.color,
531
- left: firstItemPin.x,
532
- top: firstItemPin.y,
533
- position: "absolute",
534
- cursor: "pointer"
708
+ type: "button",
709
+ className: cx(
710
+ "feedtack-color-swatch",
711
+ state.selectedColor === color && "selected"
712
+ ),
713
+ style: { background: color },
714
+ onClick: () => state.setSelectedColor(color),
715
+ title: color
535
716
  },
536
- onClick: () => setOpenThreadId(openThreadId === item.payload.id ? null : item.payload.id),
537
- children: unread && /* @__PURE__ */ jsx("div", { className: "feedtack-pin-badge" })
538
- },
539
- item.payload.id
540
- );
541
- }),
542
- openThreadId && (() => {
543
- const item = feedbackItems.find((i) => i.payload.id === openThreadId);
544
- if (!item) return null;
545
- const pin = item.payload.pins[0];
546
- const pos = getAnchoredPosition(pin.x, pin.y);
547
- return /* @__PURE__ */ jsxs("div", { className: `feedtack-thread${classes.thread ? ` ${classes.thread}` : ""}`, style: { position: "fixed", ...pos }, children: [
548
- /* @__PURE__ */ jsx("strong", { style: { fontSize: 13 }, children: item.payload.submittedBy.name }),
549
- /* @__PURE__ */ jsx("p", { style: { fontSize: 13 }, children: item.payload.comment }),
550
- item.replies.map((r) => /* @__PURE__ */ jsxs("div", { style: { borderTop: "1px solid #f3f4f6", paddingTop: 8 }, children: [
551
- /* @__PURE__ */ jsx("span", { style: { fontSize: 12, fontWeight: 600 }, children: r.author.name }),
552
- /* @__PURE__ */ jsx("p", { style: { fontSize: 12 }, children: r.body })
553
- ] }, r.id)),
554
- /* @__PURE__ */ jsx(
555
- "textarea",
717
+ color
718
+ )) }),
719
+ state.pendingPins.map((pin) => /* @__PURE__ */ jsx2(
720
+ "div",
556
721
  {
557
- placeholder: "Reply\u2026",
558
- value: replyBody,
559
- onChange: (e) => setReplyBody(e.target.value),
560
- style: { width: "100%", fontSize: 12, padding: 6, borderRadius: 6, border: "1px solid #e5e7eb", marginTop: 4 }
722
+ className: cx("feedtack-pin-marker", classes.pinMarker),
723
+ style: {
724
+ background: pin.color,
725
+ left: pin.x,
726
+ top: pin.y,
727
+ position: "absolute"
728
+ }
729
+ },
730
+ `${pin.x}-${pin.y}-${pin.color}`
731
+ )),
732
+ state.showForm && /* @__PURE__ */ jsxs2(
733
+ "div",
734
+ {
735
+ className: cx("feedtack-form", classes.form),
736
+ style: { position: "fixed", ...formPos },
737
+ children: [
738
+ /* @__PURE__ */ jsx2(
739
+ "textarea",
740
+ {
741
+ className: state.commentError ? "error" : "",
742
+ placeholder: "What's the issue? (required)",
743
+ value: state.comment,
744
+ onChange: (e) => {
745
+ state.setComment(e.target.value);
746
+ state.setCommentError(false);
747
+ },
748
+ ref: (el) => el?.focus()
749
+ }
750
+ ),
751
+ state.commentError && /* @__PURE__ */ jsx2("span", { className: "feedtack-error-msg", children: "Comment is required" }),
752
+ /* @__PURE__ */ jsxs2("div", { className: "feedtack-sentiment", children: [
753
+ /* @__PURE__ */ jsx2(
754
+ "button",
755
+ {
756
+ type: "button",
757
+ className: state.sentiment === "satisfied" ? "selected" : "",
758
+ onClick: () => state.setSentiment(
759
+ state.sentiment === "satisfied" ? null : "satisfied"
760
+ ),
761
+ children: sentimentLabels.satisfied ?? "\u{1F60A} Satisfied"
762
+ }
763
+ ),
764
+ /* @__PURE__ */ jsx2(
765
+ "button",
766
+ {
767
+ type: "button",
768
+ className: state.sentiment === "dissatisfied" ? "selected" : "",
769
+ onClick: () => state.setSentiment(
770
+ state.sentiment === "dissatisfied" ? null : "dissatisfied"
771
+ ),
772
+ children: sentimentLabels.dissatisfied ?? "\u{1F61E} Dissatisfied"
773
+ }
774
+ )
775
+ ] }),
776
+ /* @__PURE__ */ jsxs2("div", { className: "feedtack-form-actions", children: [
777
+ /* @__PURE__ */ jsx2(
778
+ "button",
779
+ {
780
+ type: "button",
781
+ className: "feedtack-btn-cancel",
782
+ onClick: state.deactivatePinMode,
783
+ children: "Cancel"
784
+ }
785
+ ),
786
+ /* @__PURE__ */ jsx2(
787
+ "button",
788
+ {
789
+ type: "button",
790
+ className: "feedtack-btn-submit",
791
+ onClick: state.handleSubmit,
792
+ disabled: state.submitting,
793
+ children: state.submitting ? "Sending\u2026" : "Submit"
794
+ }
795
+ )
796
+ ] })
797
+ ]
561
798
  }
562
799
  ),
563
- /* @__PURE__ */ jsxs("div", { style: { display: "flex", gap: 6, flexWrap: "wrap" }, children: [
564
- /* @__PURE__ */ jsx("button", { className: "feedtack-btn-submit", style: { fontSize: 12, padding: "4px 10px" }, onClick: () => handleReply(openThreadId), children: "Reply" }),
565
- /* @__PURE__ */ jsx("button", { className: "feedtack-btn-cancel", style: { fontSize: 12 }, onClick: () => handleResolve(openThreadId), children: "Mark Resolved" }),
566
- /* @__PURE__ */ jsx("button", { className: "feedtack-btn-cancel", style: { fontSize: 12 }, onClick: () => handleArchive(openThreadId), children: "Archive" }),
567
- /* @__PURE__ */ jsx("button", { className: "feedtack-btn-cancel", style: { fontSize: 12 }, onClick: () => setOpenThreadId(null), children: "Close" })
568
- ] })
569
- ] });
570
- })(),
571
- loading && /* @__PURE__ */ jsx("div", { className: "feedtack-loading", children: "Loading feedback\u2026" })
572
- ] });
800
+ !state.loading && state.feedbackItems.filter((item) => !state.isArchivedForUser(item)).map((item) => {
801
+ const pin = item.payload.pins[0];
802
+ return /* @__PURE__ */ jsx2(
803
+ "button",
804
+ {
805
+ type: "button",
806
+ className: cx("feedtack-pin-marker", classes.pinMarker),
807
+ style: {
808
+ background: pin.color,
809
+ left: pin.x,
810
+ top: pin.y,
811
+ position: "absolute",
812
+ cursor: "pointer"
813
+ },
814
+ onClick: () => state.setOpenThreadId(
815
+ state.openThreadId === item.payload.id ? null : item.payload.id
816
+ ),
817
+ children: state.hasUnread(item) && /* @__PURE__ */ jsx2("div", { className: "feedtack-pin-badge" })
818
+ },
819
+ item.payload.id
820
+ );
821
+ }),
822
+ openItem && /* @__PURE__ */ jsx2(
823
+ ThreadPanel,
824
+ {
825
+ item: openItem,
826
+ replyBody: state.replyBody,
827
+ onReplyBodyChange: state.setReplyBody,
828
+ onReply: () => state.handleReply(openItem.payload.id),
829
+ onResolve: () => state.handleResolve(openItem.payload.id),
830
+ onArchive: () => state.handleArchive(openItem.payload.id),
831
+ onClose: () => state.setOpenThreadId(null),
832
+ className: classes.thread
833
+ }
834
+ ),
835
+ state.loading && /* @__PURE__ */ jsx2("div", { className: "feedtack-loading", children: "Loading feedback\u2026" })
836
+ ]
837
+ }
838
+ );
573
839
  }
574
840
 
575
841
  // src/react/useFeedtack.ts
@@ -22,6 +22,10 @@ interface FeedtackPinTarget {
22
22
  selector: string;
23
23
  /** True when no stable selector was found — downstream consumers should not rely on selector for automated targeting */
24
24
  best_effort: boolean;
25
+ /** data-testid attribute value if present, null otherwise — always shipped for downstream consumers */
26
+ testId: string | null;
27
+ /** Readable DOM ancestry: "div.hero > div.card > button.btn.btn-primary". Walks up to body or nearest data-testid ancestor. Null when element itself has a data-testid (testId is sufficient). */
28
+ elementPath: string | null;
25
29
  tagName: string;
26
30
  /** Trimmed text content of the element, max 200 chars */
27
31
  textContent: string;
@@ -143,4 +147,4 @@ interface FeedtackTheme {
143
147
  /** Maps FeedtackTheme fields to CSS custom properties on #feedtack-root */
144
148
  declare function themeToCSS(theme: FeedtackTheme): Record<string, string>;
145
149
 
146
- export { type FeedtackAdapter as F, SCHEMA_VERSION as S, type FeedtackPayload as a, type FeedtackReply as b, type FeedtackResolution as c, type FeedtackFilter as d, type FeedbackItem as e, type FeedtackPinTarget as f, type FeedtackDeviceMeta as g, type FeedtackPageMeta as h, type FeedtackViewportMeta as i, type FeedtackArchive as j, type FeedtackBoundingRect as k, type FeedtackPin as l, type FeedtackSentiment as m, type FeedtackTheme as n, type FeedtackUser as o, themeToCSS as t };
150
+ export { type FeedtackAdapter as F, SCHEMA_VERSION as S, type FeedtackPayload as a, type FeedtackReply as b, type FeedtackResolution as c, type FeedtackFilter as d, type FeedbackItem as e, type FeedtackDeviceMeta as f, type FeedtackPageMeta as g, type FeedtackViewportMeta as h, type FeedtackPinTarget as i, type FeedtackArchive as j, type FeedtackBoundingRect as k, type FeedtackPin as l, type FeedtackSentiment as m, type FeedtackTheme as n, type FeedtackUser as o, themeToCSS as t };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "feedtack",
3
- "version": "0.0.2",
3
+ "version": "0.1.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",
@@ -28,14 +28,21 @@
28
28
  "dev": "tsup --watch",
29
29
  "test": "vitest run",
30
30
  "test:watch": "vitest",
31
- "lint": "eslint src/",
32
- "prepublishOnly": "pnpm test && pnpm build"
31
+ "lint": "biome check src/",
32
+ "lint:fix": "biome check --fix src/",
33
+ "release": "release-it",
34
+ "prepublishOnly": "pnpm test && pnpm build",
35
+ "prepare": "husky"
33
36
  },
34
37
  "peerDependencies": {
35
38
  "react": ">=18.0.0",
36
39
  "react-dom": ">=18.0.0"
37
40
  },
38
41
  "devDependencies": {
42
+ "@biomejs/biome": "^2.4.11",
43
+ "@commitlint/cli": "^20.5.0",
44
+ "@commitlint/config-conventional": "^20.5.0",
45
+ "@release-it/conventional-changelog": "^10.0.6",
39
46
  "@testing-library/jest-dom": "^6.4.0",
40
47
  "@testing-library/react": "^16.0.0",
41
48
  "@types/node": "^22.0.0",
@@ -43,10 +50,18 @@
43
50
  "@types/react-dom": "^18.3.0",
44
51
  "@vitejs/plugin-react": "^4.3.0",
45
52
  "eslint": "^9.0.0",
53
+ "husky": "^9.1.7",
46
54
  "jsdom": "^25.0.0",
55
+ "lint-staged": "^16.4.0",
56
+ "release-it": "^19.2.4",
47
57
  "tsup": "^8.5.1",
48
58
  "typescript": "^5.5.0",
49
59
  "typescript-eslint": "^8.0.0",
50
60
  "vitest": "^2.0.0"
61
+ },
62
+ "lint-staged": {
63
+ "*.{ts,tsx}": [
64
+ "biome check --fix --no-errors-on-unmatched"
65
+ ]
51
66
  }
52
67
  }