feedtack 0.0.1
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/LICENSE +21 -0
- package/README.md +134 -0
- package/dist/adapter-DvQFXmyi.d.ts +125 -0
- package/dist/chunk-VSVTP7O5.js +105 -0
- package/dist/index.d.ts +48 -0
- package/dist/index.js +80 -0
- package/dist/react/index.d.ts +26 -0
- package/dist/react/index.js +557 -0
- package/package.json +52 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Trillium Smith
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
# feedtack
|
|
2
|
+
|
|
3
|
+
> Click anywhere. Drop a pin. Get a payload a developer can act on.
|
|
4
|
+
|
|
5
|
+
**feedtack** is a drop-in React feedback overlay. Non-technical stakeholders click anywhere on a page, leave a comment, and feedtack emits a structured JSON payload so complete that an LLM can attempt a first-pass fix before consuming developer hours.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
npm install feedtack
|
|
11
|
+
# or
|
|
12
|
+
pnpm add feedtack
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
## Quick start
|
|
16
|
+
|
|
17
|
+
```tsx
|
|
18
|
+
import { FeedtackProvider } from 'feedtack/react'
|
|
19
|
+
import { ConsoleAdapter } from 'feedtack'
|
|
20
|
+
|
|
21
|
+
export default function App() {
|
|
22
|
+
return (
|
|
23
|
+
<FeedtackProvider
|
|
24
|
+
adapter={new ConsoleAdapter()}
|
|
25
|
+
currentUser={{ id: 'u1', name: 'Trillium', role: 'admin' }}
|
|
26
|
+
>
|
|
27
|
+
<YourApp />
|
|
28
|
+
</FeedtackProvider>
|
|
29
|
+
)
|
|
30
|
+
}
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## Production — webhook adapter
|
|
34
|
+
|
|
35
|
+
```tsx
|
|
36
|
+
import { FeedtackProvider } from 'feedtack/react'
|
|
37
|
+
import { WebhookAdapter } from 'feedtack'
|
|
38
|
+
|
|
39
|
+
const adapter = new WebhookAdapter({
|
|
40
|
+
submitUrl: 'https://your-app.com/api/feedtack',
|
|
41
|
+
updateUrl: 'https://your-app.com/api/feedtack/update', // optional
|
|
42
|
+
loadFeedback: async (filter) => {
|
|
43
|
+
const res = await fetch(`/api/feedtack?pathname=${filter?.pathname ?? ''}`)
|
|
44
|
+
return res.json()
|
|
45
|
+
},
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
export default function App() {
|
|
49
|
+
return (
|
|
50
|
+
<FeedtackProvider
|
|
51
|
+
adapter={adapter}
|
|
52
|
+
currentUser={{ id: 'u1', name: 'Alice', role: 'designer', avatarUrl: '/alice.jpg' }}
|
|
53
|
+
hotkey="p" // default: Shift+P
|
|
54
|
+
adminOnly // only show button to users with role === 'admin'
|
|
55
|
+
onError={console.error}
|
|
56
|
+
>
|
|
57
|
+
<YourApp />
|
|
58
|
+
</FeedtackProvider>
|
|
59
|
+
)
|
|
60
|
+
}
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
## Custom adapter
|
|
64
|
+
|
|
65
|
+
```ts
|
|
66
|
+
import type { FeedtackAdapter } from 'feedtack'
|
|
67
|
+
|
|
68
|
+
class MySupabaseAdapter implements FeedtackAdapter {
|
|
69
|
+
async submit(payload) { /* POST to supabase */ }
|
|
70
|
+
async reply(feedbackId, reply) { /* insert reply */ }
|
|
71
|
+
async resolve(feedbackId, resolution) { /* update resolved */ }
|
|
72
|
+
async archive(feedbackId, userId) { /* insert archive record */ }
|
|
73
|
+
async loadFeedback(filter) { /* select from supabase */ }
|
|
74
|
+
}
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
## The payload
|
|
78
|
+
|
|
79
|
+
Every pin emits a versioned JSON payload:
|
|
80
|
+
|
|
81
|
+
```json
|
|
82
|
+
{
|
|
83
|
+
"schemaVersion": "1.0.0",
|
|
84
|
+
"id": "ft_01j...",
|
|
85
|
+
"timestamp": "2026-04-09T13:42:00.000Z",
|
|
86
|
+
"submittedBy": { "id": "u1", "name": "Alice", "role": "designer" },
|
|
87
|
+
"comment": "This button doesn't do anything",
|
|
88
|
+
"sentiment": "dissatisfied",
|
|
89
|
+
"pins": [{
|
|
90
|
+
"index": 1,
|
|
91
|
+
"color": "#ef4444",
|
|
92
|
+
"x": 420, "y": 812,
|
|
93
|
+
"xPct": 29.2, "yPct": 78.4,
|
|
94
|
+
"target": {
|
|
95
|
+
"selector": "#submit-btn",
|
|
96
|
+
"best_effort": false,
|
|
97
|
+
"tagName": "BUTTON",
|
|
98
|
+
"textContent": "Place Order",
|
|
99
|
+
"attributes": { "id": "submit-btn", "disabled": "true" },
|
|
100
|
+
"boundingRect": { "x": 420, "y": 812, "width": 200, "height": 44 }
|
|
101
|
+
}
|
|
102
|
+
}],
|
|
103
|
+
"page": { "url": "https://app.example.com/checkout", "pathname": "/checkout", "title": "Checkout" },
|
|
104
|
+
"viewport": { "width": 1440, "height": 900, "scrollX": 0, "scrollY": 812, "devicePixelRatio": 2 },
|
|
105
|
+
"device": { "userAgent": "Mozilla/5.0...", "platform": "MacIntel", "touchEnabled": false }
|
|
106
|
+
}
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
## `useFeedtack` hook
|
|
110
|
+
|
|
111
|
+
```tsx
|
|
112
|
+
import { useFeedtack } from 'feedtack/react'
|
|
113
|
+
|
|
114
|
+
function MyButton() {
|
|
115
|
+
const { activatePinMode, isPinModeActive } = useFeedtack()
|
|
116
|
+
return <button onClick={activatePinMode}>{isPinModeActive ? 'Cancel' : 'Give Feedback'}</button>
|
|
117
|
+
}
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
## What feedtack does NOT do
|
|
121
|
+
|
|
122
|
+
- LLM triage or routing (downstream concern — feedtack emits, others act)
|
|
123
|
+
- Developer dashboard or inbox
|
|
124
|
+
- Screenshot annotation
|
|
125
|
+
|
|
126
|
+
## ICEBOX
|
|
127
|
+
|
|
128
|
+
- Script tag CDN distribution
|
|
129
|
+
- Next.js plugin
|
|
130
|
+
- `allowedCaptures` config for scoping DOM access
|
|
131
|
+
|
|
132
|
+
## License
|
|
133
|
+
|
|
134
|
+
MIT
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
declare const SCHEMA_VERSION = "1.0.0";
|
|
2
|
+
interface FeedtackUser {
|
|
3
|
+
/** Unique identifier — used for attribution across pins, replies, resolutions, archives */
|
|
4
|
+
id: string;
|
|
5
|
+
/** Display name shown in threads */
|
|
6
|
+
name: string;
|
|
7
|
+
/** Controls UI access, e.g. 'admin' | 'designer' | 'stakeholder' | 'partner' */
|
|
8
|
+
role: string;
|
|
9
|
+
/** Shown in reply threads next to name */
|
|
10
|
+
avatarUrl?: string;
|
|
11
|
+
/** Reserved for future notification use */
|
|
12
|
+
email?: string;
|
|
13
|
+
}
|
|
14
|
+
interface FeedtackBoundingRect {
|
|
15
|
+
x: number;
|
|
16
|
+
y: number;
|
|
17
|
+
width: number;
|
|
18
|
+
height: number;
|
|
19
|
+
}
|
|
20
|
+
interface FeedtackPinTarget {
|
|
21
|
+
/** CSS selector path to the clicked element */
|
|
22
|
+
selector: string;
|
|
23
|
+
/** True when no stable selector was found — downstream consumers should not rely on selector for automated targeting */
|
|
24
|
+
best_effort: boolean;
|
|
25
|
+
tagName: string;
|
|
26
|
+
/** Trimmed text content of the element, max 200 chars */
|
|
27
|
+
textContent: string;
|
|
28
|
+
attributes: Record<string, string>;
|
|
29
|
+
boundingRect: FeedtackBoundingRect;
|
|
30
|
+
}
|
|
31
|
+
interface FeedtackPin {
|
|
32
|
+
/** 1-based index within the session */
|
|
33
|
+
index: number;
|
|
34
|
+
/** Hex color chosen by user from the fixed palette */
|
|
35
|
+
color: string;
|
|
36
|
+
/** Document-relative X coordinate (clientX + scrollX) */
|
|
37
|
+
x: number;
|
|
38
|
+
/** Document-relative Y coordinate (clientY + scrollY) */
|
|
39
|
+
y: number;
|
|
40
|
+
/** X as percentage of document width */
|
|
41
|
+
xPct: number;
|
|
42
|
+
/** Y as percentage of document height */
|
|
43
|
+
yPct: number;
|
|
44
|
+
target: FeedtackPinTarget;
|
|
45
|
+
}
|
|
46
|
+
interface FeedtackPageMeta {
|
|
47
|
+
url: string;
|
|
48
|
+
pathname: string;
|
|
49
|
+
title: string;
|
|
50
|
+
}
|
|
51
|
+
interface FeedtackViewportMeta {
|
|
52
|
+
width: number;
|
|
53
|
+
height: number;
|
|
54
|
+
scrollX: number;
|
|
55
|
+
scrollY: number;
|
|
56
|
+
devicePixelRatio: number;
|
|
57
|
+
}
|
|
58
|
+
interface FeedtackDeviceMeta {
|
|
59
|
+
userAgent: string;
|
|
60
|
+
platform: string;
|
|
61
|
+
touchEnabled: boolean;
|
|
62
|
+
}
|
|
63
|
+
type FeedtackSentiment = 'satisfied' | 'dissatisfied' | null;
|
|
64
|
+
interface FeedtackPayload {
|
|
65
|
+
schemaVersion: string;
|
|
66
|
+
/** Unique feedback ID, e.g. ft_01j... */
|
|
67
|
+
id: string;
|
|
68
|
+
/** ISO 8601 UTC */
|
|
69
|
+
timestamp: string;
|
|
70
|
+
submittedBy: FeedtackUser;
|
|
71
|
+
comment: string;
|
|
72
|
+
sentiment: FeedtackSentiment;
|
|
73
|
+
/** At least one pin required */
|
|
74
|
+
pins: FeedtackPin[];
|
|
75
|
+
page: FeedtackPageMeta;
|
|
76
|
+
viewport: FeedtackViewportMeta;
|
|
77
|
+
device: FeedtackDeviceMeta;
|
|
78
|
+
}
|
|
79
|
+
interface FeedtackReply {
|
|
80
|
+
id: string;
|
|
81
|
+
feedbackId: string;
|
|
82
|
+
author: FeedtackUser;
|
|
83
|
+
body: string;
|
|
84
|
+
/** ISO 8601 UTC */
|
|
85
|
+
timestamp: string;
|
|
86
|
+
}
|
|
87
|
+
interface FeedtackResolution {
|
|
88
|
+
feedbackId: string;
|
|
89
|
+
resolvedBy: FeedtackUser;
|
|
90
|
+
/** ISO 8601 UTC */
|
|
91
|
+
timestamp: string;
|
|
92
|
+
}
|
|
93
|
+
interface FeedtackArchive {
|
|
94
|
+
feedbackId: string;
|
|
95
|
+
archivedBy: FeedtackUser;
|
|
96
|
+
/** ISO 8601 UTC */
|
|
97
|
+
timestamp: string;
|
|
98
|
+
}
|
|
99
|
+
interface FeedbackItem {
|
|
100
|
+
payload: FeedtackPayload;
|
|
101
|
+
replies: FeedtackReply[];
|
|
102
|
+
resolutions: FeedtackResolution[];
|
|
103
|
+
archives: FeedtackArchive[];
|
|
104
|
+
}
|
|
105
|
+
interface FeedtackFilter {
|
|
106
|
+
url?: string;
|
|
107
|
+
pathname?: string;
|
|
108
|
+
userId?: string;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Plugin contract — implement this interface to create a custom feedtack backend */
|
|
112
|
+
interface FeedtackAdapter {
|
|
113
|
+
/** Submit new feedback payload */
|
|
114
|
+
submit(payload: FeedtackPayload): Promise<void>;
|
|
115
|
+
/** Post a reply to an existing feedback item */
|
|
116
|
+
reply(feedbackId: string, reply: Omit<FeedtackReply, 'id' | 'feedbackId'>): Promise<void>;
|
|
117
|
+
/** Mark a feedback item as resolved */
|
|
118
|
+
resolve(feedbackId: string, resolution: Omit<FeedtackResolution, 'feedbackId'>): Promise<void>;
|
|
119
|
+
/** Archive a feedback item for a specific user */
|
|
120
|
+
archive(feedbackId: string, userId: string): Promise<void>;
|
|
121
|
+
/** Load persisted feedback items, optionally filtered */
|
|
122
|
+
loadFeedback(filter?: FeedtackFilter): Promise<FeedbackItem[]>;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
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 FeedtackUser as n };
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
// src/types/payload.ts
|
|
2
|
+
var SCHEMA_VERSION = "1.0.0";
|
|
3
|
+
|
|
4
|
+
// src/capture/target.ts
|
|
5
|
+
function getCSSSelector(element) {
|
|
6
|
+
const parts = [];
|
|
7
|
+
let current = element;
|
|
8
|
+
while (current && current !== document.body) {
|
|
9
|
+
let selector = current.tagName.toLowerCase();
|
|
10
|
+
const parent = current.parentElement;
|
|
11
|
+
if (parent) {
|
|
12
|
+
const siblings = Array.from(parent.children).filter(
|
|
13
|
+
(c) => c.tagName === current.tagName
|
|
14
|
+
);
|
|
15
|
+
if (siblings.length > 1) {
|
|
16
|
+
const index = siblings.indexOf(current) + 1;
|
|
17
|
+
selector += `:nth-of-type(${index})`;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
parts.unshift(selector);
|
|
21
|
+
current = current.parentElement;
|
|
22
|
+
}
|
|
23
|
+
return parts.join(" > ");
|
|
24
|
+
}
|
|
25
|
+
function getTargetMeta(element) {
|
|
26
|
+
const id = element.getAttribute("id");
|
|
27
|
+
const testId = element.getAttribute("data-testid");
|
|
28
|
+
let selector;
|
|
29
|
+
let best_effort;
|
|
30
|
+
if (id) {
|
|
31
|
+
selector = `#${id}`;
|
|
32
|
+
best_effort = false;
|
|
33
|
+
} else if (testId) {
|
|
34
|
+
selector = `[data-testid="${testId}"]`;
|
|
35
|
+
best_effort = false;
|
|
36
|
+
} else {
|
|
37
|
+
selector = getCSSSelector(element);
|
|
38
|
+
best_effort = true;
|
|
39
|
+
}
|
|
40
|
+
const rect = element.getBoundingClientRect();
|
|
41
|
+
const attrs = {};
|
|
42
|
+
for (const attr of Array.from(element.attributes)) {
|
|
43
|
+
attrs[attr.name] = attr.value;
|
|
44
|
+
}
|
|
45
|
+
return {
|
|
46
|
+
selector,
|
|
47
|
+
best_effort,
|
|
48
|
+
tagName: element.tagName,
|
|
49
|
+
textContent: (element.textContent ?? "").trim().slice(0, 200),
|
|
50
|
+
attributes: attrs,
|
|
51
|
+
boundingRect: {
|
|
52
|
+
x: rect.x,
|
|
53
|
+
y: rect.y,
|
|
54
|
+
width: rect.width,
|
|
55
|
+
height: rect.height
|
|
56
|
+
}
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// src/capture/meta.ts
|
|
61
|
+
function getViewportMeta() {
|
|
62
|
+
return {
|
|
63
|
+
width: window.innerWidth,
|
|
64
|
+
height: window.innerHeight,
|
|
65
|
+
scrollX: window.scrollX,
|
|
66
|
+
scrollY: window.scrollY,
|
|
67
|
+
devicePixelRatio: window.devicePixelRatio
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
function getPageMeta() {
|
|
71
|
+
return {
|
|
72
|
+
url: window.location.href,
|
|
73
|
+
pathname: window.location.pathname,
|
|
74
|
+
title: document.title
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
function getDeviceMeta() {
|
|
78
|
+
return {
|
|
79
|
+
userAgent: navigator.userAgent,
|
|
80
|
+
platform: navigator.platform,
|
|
81
|
+
touchEnabled: navigator.maxTouchPoints > 0
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
function getPinCoords(event) {
|
|
85
|
+
const x = event.clientX + window.scrollX;
|
|
86
|
+
const y = event.clientY + window.scrollY;
|
|
87
|
+
const docWidth = document.documentElement.scrollWidth;
|
|
88
|
+
const docHeight = document.documentElement.scrollHeight;
|
|
89
|
+
return {
|
|
90
|
+
x,
|
|
91
|
+
y,
|
|
92
|
+
xPct: Number((x / docWidth * 100).toFixed(2)),
|
|
93
|
+
yPct: Number((y / docHeight * 100).toFixed(2))
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export {
|
|
98
|
+
SCHEMA_VERSION,
|
|
99
|
+
getCSSSelector,
|
|
100
|
+
getTargetMeta,
|
|
101
|
+
getViewportMeta,
|
|
102
|
+
getPageMeta,
|
|
103
|
+
getDeviceMeta,
|
|
104
|
+
getPinCoords
|
|
105
|
+
};
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
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 './adapter-DvQFXmyi.js';
|
|
2
|
+
export { j as FeedtackArchive, k as FeedtackBoundingRect, l as FeedtackPin, m as FeedtackSentiment, n as FeedtackUser, S as SCHEMA_VERSION } from './adapter-DvQFXmyi.js';
|
|
3
|
+
|
|
4
|
+
/** Development adapter — logs all operations to the browser console */
|
|
5
|
+
declare class ConsoleAdapter implements FeedtackAdapter {
|
|
6
|
+
submit(payload: FeedtackPayload): Promise<void>;
|
|
7
|
+
reply(feedbackId: string, reply: Omit<FeedtackReply, 'id' | 'feedbackId'>): Promise<void>;
|
|
8
|
+
resolve(feedbackId: string, resolution: Omit<FeedtackResolution, 'feedbackId'>): Promise<void>;
|
|
9
|
+
archive(feedbackId: string, userId: string): Promise<void>;
|
|
10
|
+
loadFeedback(_filter?: FeedtackFilter): Promise<FeedbackItem[]>;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
interface WebhookAdapterConfig {
|
|
14
|
+
/** URL to POST new feedback payloads to */
|
|
15
|
+
submitUrl: string;
|
|
16
|
+
/** URL to POST reply/resolve/archive state updates to */
|
|
17
|
+
updateUrl?: string;
|
|
18
|
+
/** Required: async function that returns persisted feedback items */
|
|
19
|
+
loadFeedback: (filter?: FeedtackFilter) => Promise<FeedbackItem[]>;
|
|
20
|
+
}
|
|
21
|
+
/** Production adapter — POSTs feedback as JSON to a webhook endpoint */
|
|
22
|
+
declare class WebhookAdapter implements FeedtackAdapter {
|
|
23
|
+
private config;
|
|
24
|
+
constructor(config: WebhookAdapterConfig);
|
|
25
|
+
private post;
|
|
26
|
+
submit(payload: FeedtackPayload): Promise<void>;
|
|
27
|
+
reply(feedbackId: string, reply: Omit<FeedtackReply, 'id' | 'feedbackId'>): Promise<void>;
|
|
28
|
+
resolve(feedbackId: string, resolution: Omit<FeedtackResolution, 'feedbackId'>): Promise<void>;
|
|
29
|
+
archive(feedbackId: string, userId: string): Promise<void>;
|
|
30
|
+
loadFeedback(filter?: FeedtackFilter): Promise<FeedbackItem[]>;
|
|
31
|
+
}
|
|
32
|
+
|
|
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
|
+
declare function getViewportMeta(): FeedtackViewportMeta;
|
|
39
|
+
declare function getPageMeta(): FeedtackPageMeta;
|
|
40
|
+
declare function getDeviceMeta(): FeedtackDeviceMeta;
|
|
41
|
+
declare function getPinCoords(event: MouseEvent): {
|
|
42
|
+
x: number;
|
|
43
|
+
y: number;
|
|
44
|
+
xPct: number;
|
|
45
|
+
yPct: number;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
export { ConsoleAdapter, FeedbackItem, FeedtackAdapter, FeedtackDeviceMeta, FeedtackFilter, FeedtackPageMeta, FeedtackPayload, FeedtackPinTarget, FeedtackReply, FeedtackResolution, FeedtackViewportMeta, WebhookAdapter, type WebhookAdapterConfig, getCSSSelector, getDeviceMeta, getPageMeta, getPinCoords, getTargetMeta, getViewportMeta };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import {
|
|
2
|
+
SCHEMA_VERSION,
|
|
3
|
+
getCSSSelector,
|
|
4
|
+
getDeviceMeta,
|
|
5
|
+
getPageMeta,
|
|
6
|
+
getPinCoords,
|
|
7
|
+
getTargetMeta,
|
|
8
|
+
getViewportMeta
|
|
9
|
+
} from "./chunk-VSVTP7O5.js";
|
|
10
|
+
|
|
11
|
+
// src/adapters/ConsoleAdapter.ts
|
|
12
|
+
var ConsoleAdapter = class {
|
|
13
|
+
async submit(payload) {
|
|
14
|
+
console.log("[feedtack] submit", payload);
|
|
15
|
+
}
|
|
16
|
+
async reply(feedbackId, reply) {
|
|
17
|
+
console.log("[feedtack] reply", { feedbackId, reply });
|
|
18
|
+
}
|
|
19
|
+
async resolve(feedbackId, resolution) {
|
|
20
|
+
console.log("[feedtack] resolve", { feedbackId, resolution });
|
|
21
|
+
}
|
|
22
|
+
async archive(feedbackId, userId) {
|
|
23
|
+
console.log("[feedtack] archive", { feedbackId, userId });
|
|
24
|
+
}
|
|
25
|
+
async loadFeedback(_filter) {
|
|
26
|
+
console.log("[feedtack] loadFeedback \u2014 ConsoleAdapter returns empty array");
|
|
27
|
+
return [];
|
|
28
|
+
}
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
// src/adapters/WebhookAdapter.ts
|
|
32
|
+
var WebhookAdapter = class {
|
|
33
|
+
constructor(config) {
|
|
34
|
+
this.config = config;
|
|
35
|
+
}
|
|
36
|
+
async post(url, body) {
|
|
37
|
+
let response;
|
|
38
|
+
try {
|
|
39
|
+
response = await fetch(url, {
|
|
40
|
+
method: "POST",
|
|
41
|
+
headers: { "Content-Type": "application/json" },
|
|
42
|
+
body: JSON.stringify(body)
|
|
43
|
+
});
|
|
44
|
+
} catch (err) {
|
|
45
|
+
throw new Error(`[feedtack] Network error: ${err.message}`);
|
|
46
|
+
}
|
|
47
|
+
if (!response.ok) {
|
|
48
|
+
throw new Error(`[feedtack] Webhook responded with ${response.status}`);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
async submit(payload) {
|
|
52
|
+
await this.post(this.config.submitUrl, payload);
|
|
53
|
+
}
|
|
54
|
+
async reply(feedbackId, reply) {
|
|
55
|
+
const url = this.config.updateUrl ?? this.config.submitUrl;
|
|
56
|
+
await this.post(url, { type: "reply", feedbackId, ...reply });
|
|
57
|
+
}
|
|
58
|
+
async resolve(feedbackId, resolution) {
|
|
59
|
+
const url = this.config.updateUrl ?? this.config.submitUrl;
|
|
60
|
+
await this.post(url, { type: "resolve", feedbackId, ...resolution });
|
|
61
|
+
}
|
|
62
|
+
async archive(feedbackId, userId) {
|
|
63
|
+
const url = this.config.updateUrl ?? this.config.submitUrl;
|
|
64
|
+
await this.post(url, { type: "archive", feedbackId, userId });
|
|
65
|
+
}
|
|
66
|
+
async loadFeedback(filter) {
|
|
67
|
+
return this.config.loadFeedback(filter);
|
|
68
|
+
}
|
|
69
|
+
};
|
|
70
|
+
export {
|
|
71
|
+
ConsoleAdapter,
|
|
72
|
+
SCHEMA_VERSION,
|
|
73
|
+
WebhookAdapter,
|
|
74
|
+
getCSSSelector,
|
|
75
|
+
getDeviceMeta,
|
|
76
|
+
getPageMeta,
|
|
77
|
+
getPinCoords,
|
|
78
|
+
getTargetMeta,
|
|
79
|
+
getViewportMeta
|
|
80
|
+
};
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import * as react_jsx_runtime from 'react/jsx-runtime';
|
|
2
|
+
import React from 'react';
|
|
3
|
+
import { F as FeedtackAdapter, n as FeedtackUser } from '../adapter-DvQFXmyi.js';
|
|
4
|
+
|
|
5
|
+
interface FeedtackProviderProps {
|
|
6
|
+
children: React.ReactNode;
|
|
7
|
+
adapter: FeedtackAdapter;
|
|
8
|
+
currentUser: FeedtackUser;
|
|
9
|
+
/** Keyboard shortcut to toggle pin mode. Default: 'p' (Shift+P) */
|
|
10
|
+
hotkey?: string;
|
|
11
|
+
/** Only show the activation button for users whose role is in this list */
|
|
12
|
+
adminOnly?: boolean;
|
|
13
|
+
onError?: (err: Error) => void;
|
|
14
|
+
}
|
|
15
|
+
declare function FeedtackProvider({ children, adapter, currentUser, hotkey, adminOnly, onError }: FeedtackProviderProps): react_jsx_runtime.JSX.Element;
|
|
16
|
+
|
|
17
|
+
interface FeedtackContextValue {
|
|
18
|
+
activatePinMode: () => void;
|
|
19
|
+
deactivatePinMode: () => void;
|
|
20
|
+
isPinModeActive: boolean;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Hook for host app to programmatically control feedtack */
|
|
24
|
+
declare function useFeedtack(): FeedtackContextValue;
|
|
25
|
+
|
|
26
|
+
export { FeedtackProvider, useFeedtack };
|
|
@@ -0,0 +1,557 @@
|
|
|
1
|
+
import {
|
|
2
|
+
SCHEMA_VERSION,
|
|
3
|
+
getDeviceMeta,
|
|
4
|
+
getPageMeta,
|
|
5
|
+
getPinCoords,
|
|
6
|
+
getTargetMeta,
|
|
7
|
+
getViewportMeta
|
|
8
|
+
} from "../chunk-VSVTP7O5.js";
|
|
9
|
+
|
|
10
|
+
// src/react/FeedtackProvider.tsx
|
|
11
|
+
import { useCallback, useEffect, useRef, useState } from "react";
|
|
12
|
+
|
|
13
|
+
// src/react/context.ts
|
|
14
|
+
import { createContext, useContext } from "react";
|
|
15
|
+
var FeedtackContext = createContext(null);
|
|
16
|
+
function useFeedtackContext() {
|
|
17
|
+
const ctx = useContext(FeedtackContext);
|
|
18
|
+
if (!ctx) throw new Error("useFeedtack must be used inside <FeedtackProvider>");
|
|
19
|
+
return ctx;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// src/ui/styles.ts
|
|
23
|
+
var FEEDTACK_STYLES = `
|
|
24
|
+
#feedtack-root * {
|
|
25
|
+
box-sizing: border-box;
|
|
26
|
+
margin: 0;
|
|
27
|
+
padding: 0;
|
|
28
|
+
font-family: system-ui, -apple-system, sans-serif;
|
|
29
|
+
line-height: 1.5;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
.feedtack-btn {
|
|
33
|
+
position: fixed;
|
|
34
|
+
bottom: 24px;
|
|
35
|
+
right: 24px;
|
|
36
|
+
z-index: 2147483640;
|
|
37
|
+
background: #1a1a1a;
|
|
38
|
+
color: #fff;
|
|
39
|
+
border: none;
|
|
40
|
+
border-radius: 8px;
|
|
41
|
+
padding: 8px 14px;
|
|
42
|
+
font-size: 13px;
|
|
43
|
+
font-weight: 500;
|
|
44
|
+
cursor: pointer;
|
|
45
|
+
box-shadow: 0 2px 8px rgba(0,0,0,0.25);
|
|
46
|
+
display: flex;
|
|
47
|
+
align-items: center;
|
|
48
|
+
gap: 6px;
|
|
49
|
+
transition: background 0.15s;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
.feedtack-btn:hover {
|
|
53
|
+
background: #333;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
.feedtack-btn.active {
|
|
57
|
+
background: #2563eb;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
.feedtack-crosshair * {
|
|
61
|
+
cursor: crosshair !important;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
.feedtack-pin-marker {
|
|
65
|
+
position: absolute;
|
|
66
|
+
z-index: 2147483641;
|
|
67
|
+
width: 24px;
|
|
68
|
+
height: 24px;
|
|
69
|
+
border-radius: 50% 50% 50% 0;
|
|
70
|
+
transform: rotate(-45deg) translate(-50%, -50%);
|
|
71
|
+
border: 2px solid rgba(255,255,255,0.8);
|
|
72
|
+
box-shadow: 0 2px 6px rgba(0,0,0,0.3);
|
|
73
|
+
cursor: pointer;
|
|
74
|
+
pointer-events: all;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
.feedtack-pin-badge {
|
|
78
|
+
position: absolute;
|
|
79
|
+
top: -4px;
|
|
80
|
+
right: -4px;
|
|
81
|
+
width: 10px;
|
|
82
|
+
height: 10px;
|
|
83
|
+
background: #f59e0b;
|
|
84
|
+
border-radius: 50%;
|
|
85
|
+
border: 1.5px solid #fff;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
.feedtack-color-picker {
|
|
89
|
+
display: flex;
|
|
90
|
+
gap: 6px;
|
|
91
|
+
padding: 8px;
|
|
92
|
+
background: #fff;
|
|
93
|
+
border-radius: 8px;
|
|
94
|
+
box-shadow: 0 2px 8px rgba(0,0,0,0.15);
|
|
95
|
+
position: fixed;
|
|
96
|
+
bottom: 72px;
|
|
97
|
+
right: 24px;
|
|
98
|
+
z-index: 2147483641;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
.feedtack-color-swatch {
|
|
102
|
+
width: 20px;
|
|
103
|
+
height: 20px;
|
|
104
|
+
border-radius: 50%;
|
|
105
|
+
border: 2px solid transparent;
|
|
106
|
+
cursor: pointer;
|
|
107
|
+
transition: transform 0.1s;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
.feedtack-color-swatch.selected {
|
|
111
|
+
border-color: #1a1a1a;
|
|
112
|
+
transform: scale(1.15);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
.feedtack-form {
|
|
116
|
+
position: absolute;
|
|
117
|
+
z-index: 2147483642;
|
|
118
|
+
background: #fff;
|
|
119
|
+
border-radius: 10px;
|
|
120
|
+
box-shadow: 0 4px 20px rgba(0,0,0,0.18);
|
|
121
|
+
padding: 16px;
|
|
122
|
+
width: 280px;
|
|
123
|
+
display: flex;
|
|
124
|
+
flex-direction: column;
|
|
125
|
+
gap: 10px;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
.feedtack-form textarea {
|
|
129
|
+
width: 100%;
|
|
130
|
+
border: 1.5px solid #e5e7eb;
|
|
131
|
+
border-radius: 6px;
|
|
132
|
+
padding: 8px;
|
|
133
|
+
font-size: 13px;
|
|
134
|
+
resize: vertical;
|
|
135
|
+
min-height: 80px;
|
|
136
|
+
outline: none;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
.feedtack-form textarea:focus {
|
|
140
|
+
border-color: #2563eb;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
.feedtack-form textarea.error {
|
|
144
|
+
border-color: #ef4444;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
.feedtack-error-msg {
|
|
148
|
+
font-size: 12px;
|
|
149
|
+
color: #ef4444;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
.feedtack-sentiment {
|
|
153
|
+
display: flex;
|
|
154
|
+
gap: 8px;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
.feedtack-sentiment button {
|
|
158
|
+
flex: 1;
|
|
159
|
+
padding: 6px 10px;
|
|
160
|
+
border: 1.5px solid #e5e7eb;
|
|
161
|
+
border-radius: 6px;
|
|
162
|
+
background: #fff;
|
|
163
|
+
font-size: 12px;
|
|
164
|
+
cursor: pointer;
|
|
165
|
+
transition: all 0.1s;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
.feedtack-sentiment button.selected {
|
|
169
|
+
border-color: #2563eb;
|
|
170
|
+
background: #eff6ff;
|
|
171
|
+
color: #2563eb;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
.feedtack-form-actions {
|
|
175
|
+
display: flex;
|
|
176
|
+
gap: 8px;
|
|
177
|
+
justify-content: flex-end;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
.feedtack-btn-cancel {
|
|
181
|
+
padding: 6px 12px;
|
|
182
|
+
border: 1.5px solid #e5e7eb;
|
|
183
|
+
border-radius: 6px;
|
|
184
|
+
background: #fff;
|
|
185
|
+
font-size: 13px;
|
|
186
|
+
cursor: pointer;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
.feedtack-btn-submit {
|
|
190
|
+
padding: 6px 12px;
|
|
191
|
+
border: none;
|
|
192
|
+
border-radius: 6px;
|
|
193
|
+
background: #2563eb;
|
|
194
|
+
color: #fff;
|
|
195
|
+
font-size: 13px;
|
|
196
|
+
font-weight: 500;
|
|
197
|
+
cursor: pointer;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
.feedtack-btn-submit:disabled {
|
|
201
|
+
opacity: 0.5;
|
|
202
|
+
cursor: not-allowed;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
.feedtack-thread {
|
|
206
|
+
position: absolute;
|
|
207
|
+
z-index: 2147483642;
|
|
208
|
+
background: #fff;
|
|
209
|
+
border-radius: 10px;
|
|
210
|
+
box-shadow: 0 4px 20px rgba(0,0,0,0.18);
|
|
211
|
+
padding: 16px;
|
|
212
|
+
width: 300px;
|
|
213
|
+
max-height: 400px;
|
|
214
|
+
overflow-y: auto;
|
|
215
|
+
display: flex;
|
|
216
|
+
flex-direction: column;
|
|
217
|
+
gap: 10px;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
.feedtack-loading {
|
|
221
|
+
position: fixed;
|
|
222
|
+
bottom: 70px;
|
|
223
|
+
right: 24px;
|
|
224
|
+
font-size: 12px;
|
|
225
|
+
color: #6b7280;
|
|
226
|
+
z-index: 2147483640;
|
|
227
|
+
}
|
|
228
|
+
`;
|
|
229
|
+
|
|
230
|
+
// src/ui/colors.ts
|
|
231
|
+
var PIN_PALETTE = [
|
|
232
|
+
"#ef4444",
|
|
233
|
+
// red
|
|
234
|
+
"#3b82f6",
|
|
235
|
+
// blue
|
|
236
|
+
"#22c55e",
|
|
237
|
+
// green
|
|
238
|
+
"#f59e0b",
|
|
239
|
+
// amber
|
|
240
|
+
"#a855f7",
|
|
241
|
+
// purple
|
|
242
|
+
"#ec4899"
|
|
243
|
+
// pink
|
|
244
|
+
];
|
|
245
|
+
|
|
246
|
+
// src/react/FeedtackProvider.tsx
|
|
247
|
+
import { jsx, jsxs } from "react/jsx-runtime";
|
|
248
|
+
function generateId() {
|
|
249
|
+
return `ft_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 7)}`;
|
|
250
|
+
}
|
|
251
|
+
function getAnchoredPosition(x, y) {
|
|
252
|
+
const FORM_WIDTH = 290;
|
|
253
|
+
const FORM_HEIGHT = 220;
|
|
254
|
+
const EDGE = 300;
|
|
255
|
+
const vw = window.innerWidth;
|
|
256
|
+
const vh = window.innerHeight;
|
|
257
|
+
const clientX = x - window.scrollX;
|
|
258
|
+
const clientY = y - window.scrollY;
|
|
259
|
+
const left = clientX > vw - EDGE ? void 0 : clientX + 16;
|
|
260
|
+
const right = clientX > vw - EDGE ? vw - clientX + 16 : void 0;
|
|
261
|
+
const top = clientY > vh - EDGE ? void 0 : clientY + 16;
|
|
262
|
+
const bottom = clientY > vh - EDGE ? vh - clientY + FORM_HEIGHT : void 0;
|
|
263
|
+
return { left, right, top, bottom };
|
|
264
|
+
}
|
|
265
|
+
function FeedtackProvider({ children, adapter, currentUser, hotkey = "p", adminOnly = false, onError }) {
|
|
266
|
+
const [isPinModeActive, setIsPinModeActive] = useState(false);
|
|
267
|
+
const [pendingPins, setPendingPins] = useState([]);
|
|
268
|
+
const [selectedColor, setSelectedColor] = useState(PIN_PALETTE[0]);
|
|
269
|
+
const [showForm, setShowForm] = useState(false);
|
|
270
|
+
const [comment, setComment] = useState("");
|
|
271
|
+
const [sentiment, setSentiment] = useState(null);
|
|
272
|
+
const [commentError, setCommentError] = useState(false);
|
|
273
|
+
const [submitting, setSubmitting] = useState(false);
|
|
274
|
+
const [feedbackItems, setFeedbackItems] = useState([]);
|
|
275
|
+
const [loading, setLoading] = useState(true);
|
|
276
|
+
const [openThreadId, setOpenThreadId] = useState(null);
|
|
277
|
+
const [replyBody, setReplyBody] = useState("");
|
|
278
|
+
const rootRef = useRef(null);
|
|
279
|
+
useEffect(() => {
|
|
280
|
+
if (document.getElementById("feedtack-styles")) return;
|
|
281
|
+
const style = document.createElement("style");
|
|
282
|
+
style.id = "feedtack-styles";
|
|
283
|
+
style.textContent = FEEDTACK_STYLES;
|
|
284
|
+
document.head.appendChild(style);
|
|
285
|
+
return () => {
|
|
286
|
+
style.remove();
|
|
287
|
+
};
|
|
288
|
+
}, []);
|
|
289
|
+
useEffect(() => {
|
|
290
|
+
const root = document.createElement("div");
|
|
291
|
+
root.id = "feedtack-root";
|
|
292
|
+
document.body.appendChild(root);
|
|
293
|
+
rootRef.current = root;
|
|
294
|
+
return () => {
|
|
295
|
+
root.remove();
|
|
296
|
+
};
|
|
297
|
+
}, []);
|
|
298
|
+
useEffect(() => {
|
|
299
|
+
setLoading(true);
|
|
300
|
+
adapter.loadFeedback({ pathname: window.location.pathname }).then(setFeedbackItems).catch((err) => onError?.(err)).finally(() => setLoading(false));
|
|
301
|
+
}, [adapter, onError]);
|
|
302
|
+
const activatePinMode = useCallback(() => setIsPinModeActive(true), []);
|
|
303
|
+
const deactivatePinMode = useCallback(() => {
|
|
304
|
+
setIsPinModeActive(false);
|
|
305
|
+
setPendingPins([]);
|
|
306
|
+
setShowForm(false);
|
|
307
|
+
setComment("");
|
|
308
|
+
setSentiment(null);
|
|
309
|
+
setCommentError(false);
|
|
310
|
+
}, []);
|
|
311
|
+
useEffect(() => {
|
|
312
|
+
if (isPinModeActive) {
|
|
313
|
+
document.documentElement.classList.add("feedtack-crosshair");
|
|
314
|
+
} else {
|
|
315
|
+
document.documentElement.classList.remove("feedtack-crosshair");
|
|
316
|
+
}
|
|
317
|
+
return () => document.documentElement.classList.remove("feedtack-crosshair");
|
|
318
|
+
}, [isPinModeActive]);
|
|
319
|
+
useEffect(() => {
|
|
320
|
+
const handler = (e) => {
|
|
321
|
+
if (e.key === hotkey.toUpperCase() && e.shiftKey) {
|
|
322
|
+
setIsPinModeActive((prev) => !prev);
|
|
323
|
+
}
|
|
324
|
+
if (e.key === "Escape") {
|
|
325
|
+
deactivatePinMode();
|
|
326
|
+
setOpenThreadId(null);
|
|
327
|
+
}
|
|
328
|
+
};
|
|
329
|
+
window.addEventListener("keydown", handler);
|
|
330
|
+
return () => window.removeEventListener("keydown", handler);
|
|
331
|
+
}, [hotkey, deactivatePinMode]);
|
|
332
|
+
const handlePageClick = useCallback((e) => {
|
|
333
|
+
if (!isPinModeActive) return;
|
|
334
|
+
const target = e.target;
|
|
335
|
+
if (target.closest("#feedtack-root") || target.closest(".feedtack-form") || target.closest(".feedtack-color-picker")) return;
|
|
336
|
+
e.preventDefault();
|
|
337
|
+
e.stopPropagation();
|
|
338
|
+
const coords = getPinCoords(e);
|
|
339
|
+
const targetMeta = getTargetMeta(target);
|
|
340
|
+
setPendingPins((prev) => [...prev, {
|
|
341
|
+
color: selectedColor,
|
|
342
|
+
...coords,
|
|
343
|
+
target: targetMeta
|
|
344
|
+
}]);
|
|
345
|
+
setShowForm(true);
|
|
346
|
+
}, [isPinModeActive, selectedColor]);
|
|
347
|
+
useEffect(() => {
|
|
348
|
+
document.addEventListener("click", handlePageClick, true);
|
|
349
|
+
return () => document.removeEventListener("click", handlePageClick, true);
|
|
350
|
+
}, [handlePageClick]);
|
|
351
|
+
const handleSubmit = async () => {
|
|
352
|
+
if (!comment.trim()) {
|
|
353
|
+
setCommentError(true);
|
|
354
|
+
return;
|
|
355
|
+
}
|
|
356
|
+
setSubmitting(true);
|
|
357
|
+
const payload = {
|
|
358
|
+
schemaVersion: SCHEMA_VERSION,
|
|
359
|
+
id: generateId(),
|
|
360
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
361
|
+
submittedBy: currentUser,
|
|
362
|
+
comment: comment.trim(),
|
|
363
|
+
sentiment,
|
|
364
|
+
pins: pendingPins.map((p, i) => ({ ...p, index: i + 1 })),
|
|
365
|
+
page: getPageMeta(),
|
|
366
|
+
viewport: getViewportMeta(),
|
|
367
|
+
device: getDeviceMeta()
|
|
368
|
+
};
|
|
369
|
+
try {
|
|
370
|
+
await adapter.submit(payload);
|
|
371
|
+
setFeedbackItems((prev) => [...prev, { payload, replies: [], resolutions: [], archives: [] }]);
|
|
372
|
+
deactivatePinMode();
|
|
373
|
+
} catch (err) {
|
|
374
|
+
onError?.(err);
|
|
375
|
+
} finally {
|
|
376
|
+
setSubmitting(false);
|
|
377
|
+
}
|
|
378
|
+
};
|
|
379
|
+
const handleReply = async (feedbackId) => {
|
|
380
|
+
if (!replyBody.trim()) return;
|
|
381
|
+
try {
|
|
382
|
+
await adapter.reply(feedbackId, {
|
|
383
|
+
author: currentUser,
|
|
384
|
+
body: replyBody.trim(),
|
|
385
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString()
|
|
386
|
+
});
|
|
387
|
+
setFeedbackItems((prev) => prev.map(
|
|
388
|
+
(item) => item.payload.id === feedbackId ? { ...item, replies: [...item.replies, { id: generateId(), feedbackId, author: currentUser, body: replyBody.trim(), timestamp: (/* @__PURE__ */ new Date()).toISOString() }] } : item
|
|
389
|
+
));
|
|
390
|
+
setReplyBody("");
|
|
391
|
+
} catch (err) {
|
|
392
|
+
onError?.(err);
|
|
393
|
+
}
|
|
394
|
+
};
|
|
395
|
+
const handleResolve = async (feedbackId) => {
|
|
396
|
+
try {
|
|
397
|
+
await adapter.resolve(feedbackId, { resolvedBy: currentUser, timestamp: (/* @__PURE__ */ new Date()).toISOString() });
|
|
398
|
+
setFeedbackItems((prev) => prev.map(
|
|
399
|
+
(item) => item.payload.id === feedbackId ? { ...item, resolutions: [...item.resolutions, { feedbackId, resolvedBy: currentUser, timestamp: (/* @__PURE__ */ new Date()).toISOString() }] } : item
|
|
400
|
+
));
|
|
401
|
+
} catch (err) {
|
|
402
|
+
onError?.(err);
|
|
403
|
+
}
|
|
404
|
+
};
|
|
405
|
+
const handleArchive = async (feedbackId) => {
|
|
406
|
+
try {
|
|
407
|
+
await adapter.archive(feedbackId, currentUser.id);
|
|
408
|
+
setFeedbackItems((prev) => prev.map(
|
|
409
|
+
(item) => item.payload.id === feedbackId ? { ...item, archives: [...item.archives, { feedbackId, archivedBy: currentUser, timestamp: (/* @__PURE__ */ new Date()).toISOString() }] } : item
|
|
410
|
+
));
|
|
411
|
+
setOpenThreadId(null);
|
|
412
|
+
} catch (err) {
|
|
413
|
+
onError?.(err);
|
|
414
|
+
}
|
|
415
|
+
};
|
|
416
|
+
const isArchivedForUser = (item) => item.archives.some((a) => a.archivedBy.id === currentUser.id);
|
|
417
|
+
const hasUnread = (item) => item.replies.length > 0;
|
|
418
|
+
const firstPin = pendingPins[0];
|
|
419
|
+
const formPos = firstPin ? getAnchoredPosition(firstPin.x, firstPin.y) : {};
|
|
420
|
+
const showButton = !adminOnly || currentUser.role === "admin";
|
|
421
|
+
return /* @__PURE__ */ jsxs(FeedtackContext.Provider, { value: { activatePinMode, deactivatePinMode, isPinModeActive }, children: [
|
|
422
|
+
children,
|
|
423
|
+
showButton && /* @__PURE__ */ jsxs(
|
|
424
|
+
"button",
|
|
425
|
+
{
|
|
426
|
+
className: `feedtack-btn${isPinModeActive ? " active" : ""}`,
|
|
427
|
+
onClick: () => isPinModeActive ? deactivatePinMode() : activatePinMode(),
|
|
428
|
+
title: "Toggle feedback pin mode",
|
|
429
|
+
children: [
|
|
430
|
+
"Drop Pin [Shift+",
|
|
431
|
+
hotkey.toUpperCase(),
|
|
432
|
+
"]"
|
|
433
|
+
]
|
|
434
|
+
}
|
|
435
|
+
),
|
|
436
|
+
isPinModeActive && /* @__PURE__ */ jsx("div", { className: "feedtack-color-picker", children: PIN_PALETTE.map((color) => /* @__PURE__ */ jsx(
|
|
437
|
+
"button",
|
|
438
|
+
{
|
|
439
|
+
className: `feedtack-color-swatch${selectedColor === color ? " selected" : ""}`,
|
|
440
|
+
style: { background: color },
|
|
441
|
+
onClick: () => setSelectedColor(color),
|
|
442
|
+
title: color
|
|
443
|
+
},
|
|
444
|
+
color
|
|
445
|
+
)) }),
|
|
446
|
+
pendingPins.map((pin, i) => /* @__PURE__ */ jsx(
|
|
447
|
+
"div",
|
|
448
|
+
{
|
|
449
|
+
className: "feedtack-pin-marker",
|
|
450
|
+
style: {
|
|
451
|
+
background: pin.color,
|
|
452
|
+
left: pin.x,
|
|
453
|
+
top: pin.y,
|
|
454
|
+
position: "absolute"
|
|
455
|
+
}
|
|
456
|
+
},
|
|
457
|
+
i
|
|
458
|
+
)),
|
|
459
|
+
showForm && /* @__PURE__ */ jsxs("div", { className: "feedtack-form", style: { position: "fixed", ...formPos }, children: [
|
|
460
|
+
/* @__PURE__ */ jsx(
|
|
461
|
+
"textarea",
|
|
462
|
+
{
|
|
463
|
+
className: commentError ? "error" : "",
|
|
464
|
+
placeholder: "What's the issue? (required)",
|
|
465
|
+
value: comment,
|
|
466
|
+
onChange: (e) => {
|
|
467
|
+
setComment(e.target.value);
|
|
468
|
+
setCommentError(false);
|
|
469
|
+
},
|
|
470
|
+
autoFocus: true
|
|
471
|
+
}
|
|
472
|
+
),
|
|
473
|
+
commentError && /* @__PURE__ */ jsx("span", { className: "feedtack-error-msg", children: "Comment is required" }),
|
|
474
|
+
/* @__PURE__ */ jsxs("div", { className: "feedtack-sentiment", children: [
|
|
475
|
+
/* @__PURE__ */ jsx(
|
|
476
|
+
"button",
|
|
477
|
+
{
|
|
478
|
+
className: sentiment === "satisfied" ? "selected" : "",
|
|
479
|
+
onClick: () => setSentiment(sentiment === "satisfied" ? null : "satisfied"),
|
|
480
|
+
children: "\u{1F60A} Satisfied"
|
|
481
|
+
}
|
|
482
|
+
),
|
|
483
|
+
/* @__PURE__ */ jsx(
|
|
484
|
+
"button",
|
|
485
|
+
{
|
|
486
|
+
className: sentiment === "dissatisfied" ? "selected" : "",
|
|
487
|
+
onClick: () => setSentiment(sentiment === "dissatisfied" ? null : "dissatisfied"),
|
|
488
|
+
children: "\u{1F61E} Dissatisfied"
|
|
489
|
+
}
|
|
490
|
+
)
|
|
491
|
+
] }),
|
|
492
|
+
/* @__PURE__ */ jsxs("div", { className: "feedtack-form-actions", children: [
|
|
493
|
+
/* @__PURE__ */ jsx("button", { className: "feedtack-btn-cancel", onClick: deactivatePinMode, children: "Cancel" }),
|
|
494
|
+
/* @__PURE__ */ jsx("button", { className: "feedtack-btn-submit", onClick: handleSubmit, disabled: submitting, children: submitting ? "Sending\u2026" : "Submit" })
|
|
495
|
+
] })
|
|
496
|
+
] }),
|
|
497
|
+
!loading && feedbackItems.filter((item) => !isArchivedForUser(item)).map((item) => {
|
|
498
|
+
const firstItemPin = item.payload.pins[0];
|
|
499
|
+
const unread = hasUnread(item);
|
|
500
|
+
return /* @__PURE__ */ jsx(
|
|
501
|
+
"div",
|
|
502
|
+
{
|
|
503
|
+
className: "feedtack-pin-marker",
|
|
504
|
+
style: {
|
|
505
|
+
background: firstItemPin.color,
|
|
506
|
+
left: firstItemPin.x,
|
|
507
|
+
top: firstItemPin.y,
|
|
508
|
+
position: "absolute",
|
|
509
|
+
cursor: "pointer"
|
|
510
|
+
},
|
|
511
|
+
onClick: () => setOpenThreadId(openThreadId === item.payload.id ? null : item.payload.id),
|
|
512
|
+
children: unread && /* @__PURE__ */ jsx("div", { className: "feedtack-pin-badge" })
|
|
513
|
+
},
|
|
514
|
+
item.payload.id
|
|
515
|
+
);
|
|
516
|
+
}),
|
|
517
|
+
openThreadId && (() => {
|
|
518
|
+
const item = feedbackItems.find((i) => i.payload.id === openThreadId);
|
|
519
|
+
if (!item) return null;
|
|
520
|
+
const pin = item.payload.pins[0];
|
|
521
|
+
const pos = getAnchoredPosition(pin.x, pin.y);
|
|
522
|
+
return /* @__PURE__ */ jsxs("div", { className: "feedtack-thread", style: { position: "fixed", ...pos }, children: [
|
|
523
|
+
/* @__PURE__ */ jsx("strong", { style: { fontSize: 13 }, children: item.payload.submittedBy.name }),
|
|
524
|
+
/* @__PURE__ */ jsx("p", { style: { fontSize: 13 }, children: item.payload.comment }),
|
|
525
|
+
item.replies.map((r) => /* @__PURE__ */ jsxs("div", { style: { borderTop: "1px solid #f3f4f6", paddingTop: 8 }, children: [
|
|
526
|
+
/* @__PURE__ */ jsx("span", { style: { fontSize: 12, fontWeight: 600 }, children: r.author.name }),
|
|
527
|
+
/* @__PURE__ */ jsx("p", { style: { fontSize: 12 }, children: r.body })
|
|
528
|
+
] }, r.id)),
|
|
529
|
+
/* @__PURE__ */ jsx(
|
|
530
|
+
"textarea",
|
|
531
|
+
{
|
|
532
|
+
placeholder: "Reply\u2026",
|
|
533
|
+
value: replyBody,
|
|
534
|
+
onChange: (e) => setReplyBody(e.target.value),
|
|
535
|
+
style: { width: "100%", fontSize: 12, padding: 6, borderRadius: 6, border: "1px solid #e5e7eb", marginTop: 4 }
|
|
536
|
+
}
|
|
537
|
+
),
|
|
538
|
+
/* @__PURE__ */ jsxs("div", { style: { display: "flex", gap: 6, flexWrap: "wrap" }, children: [
|
|
539
|
+
/* @__PURE__ */ jsx("button", { className: "feedtack-btn-submit", style: { fontSize: 12, padding: "4px 10px" }, onClick: () => handleReply(openThreadId), children: "Reply" }),
|
|
540
|
+
/* @__PURE__ */ jsx("button", { className: "feedtack-btn-cancel", style: { fontSize: 12 }, onClick: () => handleResolve(openThreadId), children: "Mark Resolved" }),
|
|
541
|
+
/* @__PURE__ */ jsx("button", { className: "feedtack-btn-cancel", style: { fontSize: 12 }, onClick: () => handleArchive(openThreadId), children: "Archive" }),
|
|
542
|
+
/* @__PURE__ */ jsx("button", { className: "feedtack-btn-cancel", style: { fontSize: 12 }, onClick: () => setOpenThreadId(null), children: "Close" })
|
|
543
|
+
] })
|
|
544
|
+
] });
|
|
545
|
+
})(),
|
|
546
|
+
loading && /* @__PURE__ */ jsx("div", { className: "feedtack-loading", children: "Loading feedback\u2026" })
|
|
547
|
+
] });
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
// src/react/useFeedtack.ts
|
|
551
|
+
function useFeedtack() {
|
|
552
|
+
return useFeedtackContext();
|
|
553
|
+
}
|
|
554
|
+
export {
|
|
555
|
+
FeedtackProvider,
|
|
556
|
+
useFeedtack
|
|
557
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "feedtack",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"description": "Click anywhere. Drop a pin. Get a payload a developer can act on.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"author": "Trillium Smith",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "https://github.com/trillium/feedtack"
|
|
11
|
+
},
|
|
12
|
+
"exports": {
|
|
13
|
+
".": {
|
|
14
|
+
"types": "./dist/index.d.ts",
|
|
15
|
+
"default": "./dist/index.js"
|
|
16
|
+
},
|
|
17
|
+
"./react": {
|
|
18
|
+
"types": "./dist/react/index.d.ts",
|
|
19
|
+
"default": "./dist/react/index.js"
|
|
20
|
+
}
|
|
21
|
+
},
|
|
22
|
+
"files": [
|
|
23
|
+
"dist"
|
|
24
|
+
],
|
|
25
|
+
"packageManager": "pnpm@9.0.0",
|
|
26
|
+
"scripts": {
|
|
27
|
+
"build": "tsup",
|
|
28
|
+
"dev": "tsup --watch",
|
|
29
|
+
"test": "vitest run",
|
|
30
|
+
"test:watch": "vitest",
|
|
31
|
+
"lint": "eslint src/",
|
|
32
|
+
"prepublishOnly": "pnpm test && pnpm build"
|
|
33
|
+
},
|
|
34
|
+
"peerDependencies": {
|
|
35
|
+
"react": ">=18.0.0",
|
|
36
|
+
"react-dom": ">=18.0.0"
|
|
37
|
+
},
|
|
38
|
+
"devDependencies": {
|
|
39
|
+
"@testing-library/jest-dom": "^6.4.0",
|
|
40
|
+
"@testing-library/react": "^16.0.0",
|
|
41
|
+
"@types/node": "^22.0.0",
|
|
42
|
+
"@types/react": "^18.3.0",
|
|
43
|
+
"@types/react-dom": "^18.3.0",
|
|
44
|
+
"@vitejs/plugin-react": "^4.3.0",
|
|
45
|
+
"eslint": "^9.0.0",
|
|
46
|
+
"jsdom": "^25.0.0",
|
|
47
|
+
"tsup": "^8.5.1",
|
|
48
|
+
"typescript": "^5.5.0",
|
|
49
|
+
"typescript-eslint": "^8.0.0",
|
|
50
|
+
"vitest": "^2.0.0"
|
|
51
|
+
}
|
|
52
|
+
}
|