review-lens-react 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.
- package/README.md +352 -0
- package/dist/index.d.ts +8 -0
- package/dist/review-lens-overlay.d.ts +9 -0
- package/dist/review-lens-provider.d.ts +17 -0
- package/dist/review-lens-react.js +576 -0
- package/dist/review-lens-react.umd.cjs +1 -0
- package/dist/selectors/build-element-target.d.ts +2 -0
- package/dist/sheets/google-sheets-adapter.d.ts +33 -0
- package/dist/styles.css +1 -0
- package/dist/types.d.ts +78 -0
- package/dist/url/normalize-review-url.d.ts +1 -0
- package/package.json +33 -0
package/README.md
ADDED
|
@@ -0,0 +1,352 @@
|
|
|
1
|
+
# review-lens-react
|
|
2
|
+
|
|
3
|
+
`review-lens-react` is a React overlay for UX reviews inside frontend apps. Designers can inspect real DOM elements, see computed spacing and typography details, lock an element, write feedback, and store that feedback in Google Sheets. Developers can open the same app, see page comments anchored to selectors, and resolve feedback while implementing the review.
|
|
4
|
+
|
|
5
|
+
The package is intended to be mounted by the host app only when review mode is needed. It does not add a global launcher by default.
|
|
6
|
+
|
|
7
|
+
## Features
|
|
8
|
+
|
|
9
|
+
- Inspect elements in-place with a visual overlay.
|
|
10
|
+
- Capture padding, margin, border, dimensions, font size, line height, colors, and selector metadata.
|
|
11
|
+
- Prefer stable selectors such as `data-review-id`, `data-testid`, `id`, `aria-label`, and `name`.
|
|
12
|
+
- Fall back to a generated CSS path when no stable attribute exists.
|
|
13
|
+
- Save feedback with selector, URL, content id, author, status, timestamps, CSS snapshot, and element fingerprint.
|
|
14
|
+
- Match feedback across localhost and production by `projectKey`, `contentId`, and normalized path.
|
|
15
|
+
- Use Google OAuth in the browser and Google Sheets as the feedback store.
|
|
16
|
+
- Support panel placement in all four corners.
|
|
17
|
+
|
|
18
|
+
## Install
|
|
19
|
+
|
|
20
|
+
```sh
|
|
21
|
+
npm install review-lens-react
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
Import the component and its CSS once in the host app.
|
|
25
|
+
|
|
26
|
+
```tsx
|
|
27
|
+
import {
|
|
28
|
+
ReviewLensOverlay,
|
|
29
|
+
ReviewLensProvider
|
|
30
|
+
} from "review-lens-react";
|
|
31
|
+
import "review-lens-react/styles.css";
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## Basic Usage
|
|
35
|
+
|
|
36
|
+
```tsx
|
|
37
|
+
import { useState } from "react";
|
|
38
|
+
import {
|
|
39
|
+
ReviewLensOverlay,
|
|
40
|
+
ReviewLensProvider
|
|
41
|
+
} from "review-lens-react";
|
|
42
|
+
import "review-lens-react/styles.css";
|
|
43
|
+
|
|
44
|
+
export function AppReviewMode() {
|
|
45
|
+
const [reviewOpen, setReviewOpen] = useState(false);
|
|
46
|
+
|
|
47
|
+
return (
|
|
48
|
+
<ReviewLensProvider
|
|
49
|
+
config={{
|
|
50
|
+
googleClientId: import.meta.env.VITE_GOOGLE_CLIENT_ID,
|
|
51
|
+
spreadsheetId: import.meta.env.VITE_REVIEW_LENS_SPREADSHEET_ID,
|
|
52
|
+
projectKey: "landing-pages-app",
|
|
53
|
+
contentId: "article-123"
|
|
54
|
+
}}
|
|
55
|
+
>
|
|
56
|
+
<button type="button" onClick={() => setReviewOpen(true)}>
|
|
57
|
+
Start UX review
|
|
58
|
+
</button>
|
|
59
|
+
|
|
60
|
+
<ReviewLensOverlay
|
|
61
|
+
open={reviewOpen}
|
|
62
|
+
onOpenChange={setReviewOpen}
|
|
63
|
+
placement="top-right"
|
|
64
|
+
/>
|
|
65
|
+
</ReviewLensProvider>
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
Add stable attributes to important review targets when possible:
|
|
71
|
+
|
|
72
|
+
```tsx
|
|
73
|
+
<button data-review-id="hero-cta">Register now</button>
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
## API
|
|
77
|
+
|
|
78
|
+
### `ReviewLensProvider`
|
|
79
|
+
|
|
80
|
+
Wrap the host app or the reviewed page area.
|
|
81
|
+
|
|
82
|
+
```tsx
|
|
83
|
+
<ReviewLensProvider config={config}>{children}</ReviewLensProvider>
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
Config:
|
|
87
|
+
|
|
88
|
+
| Name | Required | Description |
|
|
89
|
+
| --- | --- | --- |
|
|
90
|
+
| `googleClientId` | yes, unless `adapter` is provided | OAuth web client id from Google Cloud. |
|
|
91
|
+
| `spreadsheetId` | yes, unless `adapter` is provided | Google Sheet id from the Sheet URL. |
|
|
92
|
+
| `sheetName` | no | Feedback sheet name. Defaults to `Feedback`. |
|
|
93
|
+
| `projectKey` | yes | Stable app/project key, for example `landing-pages-app`. |
|
|
94
|
+
| `contentId` | yes | Stable content key shared by localhost and production. |
|
|
95
|
+
| `currentUrl` | no | URL to store and normalize. Defaults to `window.location.href`. |
|
|
96
|
+
| `normalizeUrl` | no | Custom URL normalization function. |
|
|
97
|
+
| `adapter` | no | Custom storage adapter for tests, demos, or a future backend. |
|
|
98
|
+
|
|
99
|
+
### `ReviewLensOverlay`
|
|
100
|
+
|
|
101
|
+
Render this when review mode should be available.
|
|
102
|
+
|
|
103
|
+
```tsx
|
|
104
|
+
<ReviewLensOverlay
|
|
105
|
+
open={reviewOpen}
|
|
106
|
+
onOpenChange={setReviewOpen}
|
|
107
|
+
placement="bottom-left"
|
|
108
|
+
showResolved={false}
|
|
109
|
+
/>
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
Props:
|
|
113
|
+
|
|
114
|
+
| Name | Required | Description |
|
|
115
|
+
| --- | --- | --- |
|
|
116
|
+
| `open` | yes | Controls whether the overlay is active. |
|
|
117
|
+
| `onOpenChange` | no | Called when the overlay requests closing. |
|
|
118
|
+
| `placement` | no | `top-left`, `top-right`, `bottom-left`, or `bottom-right`. Defaults to `top-right`. |
|
|
119
|
+
| `showResolved` | no | Shows resolved comments when true. Defaults to false. |
|
|
120
|
+
|
|
121
|
+
## Google Setup
|
|
122
|
+
|
|
123
|
+
The default adapter uses Google Identity Services in the browser and the Google Sheets API. No API key is required.
|
|
124
|
+
|
|
125
|
+
Official references:
|
|
126
|
+
|
|
127
|
+
- Google Identity Services token model: https://developers.google.com/identity/oauth2/web/guides/use-token-model
|
|
128
|
+
- Google Identity Services setup: https://developers.google.com/identity/oauth2/web/guides/load-3p-authorization-library
|
|
129
|
+
- Google Sheets API values guide: https://developers.google.com/workspace/sheets/api/guides/values
|
|
130
|
+
- Google Sheets `values.append`: https://developers.google.com/workspace/sheets/api/reference/rest/v4/spreadsheets.values/append
|
|
131
|
+
|
|
132
|
+
### 1. Create or choose a Google Cloud project
|
|
133
|
+
|
|
134
|
+
Open Google Cloud Console and choose the project that should own the OAuth client.
|
|
135
|
+
|
|
136
|
+
### 2. Enable Google Sheets API
|
|
137
|
+
|
|
138
|
+
In Google Cloud Console:
|
|
139
|
+
|
|
140
|
+
1. Go to `APIs & Services`.
|
|
141
|
+
2. Enable `Google Sheets API`.
|
|
142
|
+
|
|
143
|
+
### 3. Configure OAuth consent
|
|
144
|
+
|
|
145
|
+
In Google Cloud Console:
|
|
146
|
+
|
|
147
|
+
1. Open the Google Auth Platform or OAuth consent configuration.
|
|
148
|
+
2. Use `Internal` if the app should only be available inside your Google Workspace organization.
|
|
149
|
+
3. Add the app name, support email, and developer contact.
|
|
150
|
+
4. Add these scopes:
|
|
151
|
+
|
|
152
|
+
```text
|
|
153
|
+
https://www.googleapis.com/auth/spreadsheets
|
|
154
|
+
https://www.googleapis.com/auth/userinfo.email
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
The Sheets scope is used to read/write feedback rows. The email scope is used to match the signed-in Google account against the `Users` tab.
|
|
158
|
+
|
|
159
|
+
### 4. Create an OAuth web client
|
|
160
|
+
|
|
161
|
+
In Google Cloud Console:
|
|
162
|
+
|
|
163
|
+
1. Go to `APIs & Services` -> `Credentials`.
|
|
164
|
+
2. Create an OAuth client.
|
|
165
|
+
3. Choose `Web application`.
|
|
166
|
+
4. Add authorized JavaScript origins for every host that will run review mode.
|
|
167
|
+
|
|
168
|
+
Examples:
|
|
169
|
+
|
|
170
|
+
```text
|
|
171
|
+
http://localhost:5173
|
|
172
|
+
http://localhost:3000
|
|
173
|
+
https://your-staging-app.example.com
|
|
174
|
+
https://your-production-app.example.com
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
Use the generated client id as `googleClientId`.
|
|
178
|
+
|
|
179
|
+
### 5. Create the Google Sheet
|
|
180
|
+
|
|
181
|
+
Create one shared Google Sheet and copy its id from the URL:
|
|
182
|
+
|
|
183
|
+
```text
|
|
184
|
+
https://docs.google.com/spreadsheets/d/SPREADSHEET_ID/edit
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
Share the Sheet with every reviewer/developer who should use the overlay. Users need Sheet access because the browser writes directly to the Google Sheets API as the signed-in user.
|
|
188
|
+
|
|
189
|
+
### 6. Add the `Feedback` tab
|
|
190
|
+
|
|
191
|
+
Create a tab named `Feedback` with this exact header row:
|
|
192
|
+
|
|
193
|
+
```csv
|
|
194
|
+
id,projectKey,contentId,normalizedPath,originalUrl,selector,selectorStrategy,elementFingerprintJson,cssSnapshotJson,comment,status,authorEmail,createdAt,updatedAt,resolvedAt,resolvedBy
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
The library appends new feedback rows and updates existing rows when feedback is resolved.
|
|
198
|
+
|
|
199
|
+
### 7. Add the `Users` tab
|
|
200
|
+
|
|
201
|
+
Create a tab named `Users` with this header row:
|
|
202
|
+
|
|
203
|
+
```csv
|
|
204
|
+
email,role,active,projectKey
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
Example rows:
|
|
208
|
+
|
|
209
|
+
```csv
|
|
210
|
+
designer@example.com,designer,true,landing-pages-app
|
|
211
|
+
developer@example.com,developer,true,landing-pages-app
|
|
212
|
+
lead@example.com,admin,true,
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
Roles:
|
|
216
|
+
|
|
217
|
+
| Role | Permissions |
|
|
218
|
+
| --- | --- |
|
|
219
|
+
| `designer` | Read and create feedback. |
|
|
220
|
+
| `developer` | Read feedback and resolve it. |
|
|
221
|
+
| `admin` | Read, create, and resolve feedback. |
|
|
222
|
+
|
|
223
|
+
If `projectKey` is empty, the user role applies to every project using the Sheet. If `active` is `false`, the row is ignored.
|
|
224
|
+
|
|
225
|
+
## URL and Content Matching
|
|
226
|
+
|
|
227
|
+
Feedback is loaded by:
|
|
228
|
+
|
|
229
|
+
- `projectKey`
|
|
230
|
+
- `contentId`
|
|
231
|
+
- normalized path
|
|
232
|
+
|
|
233
|
+
By default, the normalizer removes origin, port, query params, hashes, and trailing slashes. This means these URLs match the same feedback when `projectKey` and `contentId` are equal:
|
|
234
|
+
|
|
235
|
+
```text
|
|
236
|
+
https://www.example.com/articles/123?utm_source=newsletter
|
|
237
|
+
http://localhost:5173/articles/123
|
|
238
|
+
```
|
|
239
|
+
|
|
240
|
+
If your app has a different URL model, pass a custom normalizer:
|
|
241
|
+
|
|
242
|
+
```tsx
|
|
243
|
+
<ReviewLensProvider
|
|
244
|
+
config={{
|
|
245
|
+
googleClientId,
|
|
246
|
+
spreadsheetId,
|
|
247
|
+
projectKey: "landing-pages-app",
|
|
248
|
+
contentId: article.id,
|
|
249
|
+
normalizeUrl: (url) => new URL(url).pathname.replace(/^\/preview/, "")
|
|
250
|
+
}}
|
|
251
|
+
>
|
|
252
|
+
{children}
|
|
253
|
+
</ReviewLensProvider>
|
|
254
|
+
```
|
|
255
|
+
|
|
256
|
+
## Local Development
|
|
257
|
+
|
|
258
|
+
This repository contains the package and a demo app.
|
|
259
|
+
|
|
260
|
+
```sh
|
|
261
|
+
npm install
|
|
262
|
+
npm run dev --workspace review-lens-demo
|
|
263
|
+
npm test
|
|
264
|
+
npm run typecheck
|
|
265
|
+
npm run build
|
|
266
|
+
```
|
|
267
|
+
|
|
268
|
+
The demo uses an in-memory adapter, so it does not require Google credentials.
|
|
269
|
+
|
|
270
|
+
## Custom Adapter
|
|
271
|
+
|
|
272
|
+
Use a custom adapter when testing, demoing, or replacing direct Google Sheets access with a backend later.
|
|
273
|
+
|
|
274
|
+
```tsx
|
|
275
|
+
const adapter = {
|
|
276
|
+
async getCurrentUser() {
|
|
277
|
+
return { email: "designer@example.com" };
|
|
278
|
+
},
|
|
279
|
+
async getPermissions() {
|
|
280
|
+
return ["create", "read", "resolve"];
|
|
281
|
+
},
|
|
282
|
+
async listFeedback(params) {
|
|
283
|
+
return [];
|
|
284
|
+
},
|
|
285
|
+
async createFeedback(input) {
|
|
286
|
+
return {
|
|
287
|
+
...input,
|
|
288
|
+
id: crypto.randomUUID(),
|
|
289
|
+
status: "open",
|
|
290
|
+
createdAt: new Date().toISOString(),
|
|
291
|
+
updatedAt: new Date().toISOString()
|
|
292
|
+
};
|
|
293
|
+
},
|
|
294
|
+
async resolveFeedback(id, resolvedBy) {
|
|
295
|
+
throw new Error("Implement resolveFeedback");
|
|
296
|
+
}
|
|
297
|
+
};
|
|
298
|
+
```
|
|
299
|
+
|
|
300
|
+
Then pass it to the provider:
|
|
301
|
+
|
|
302
|
+
```tsx
|
|
303
|
+
<ReviewLensProvider
|
|
304
|
+
config={{
|
|
305
|
+
adapter,
|
|
306
|
+
projectKey: "demo",
|
|
307
|
+
contentId: "article-123"
|
|
308
|
+
}}
|
|
309
|
+
>
|
|
310
|
+
{children}
|
|
311
|
+
</ReviewLensProvider>
|
|
312
|
+
```
|
|
313
|
+
|
|
314
|
+
## Troubleshooting
|
|
315
|
+
|
|
316
|
+
### Google sign-in popup does not work
|
|
317
|
+
|
|
318
|
+
Check that the current origin is listed as an authorized JavaScript origin on the OAuth web client. The origin must include protocol and host, for example `http://localhost:5173`.
|
|
319
|
+
|
|
320
|
+
### `Google Sheets request failed with 403`
|
|
321
|
+
|
|
322
|
+
Check that:
|
|
323
|
+
|
|
324
|
+
- Google Sheets API is enabled.
|
|
325
|
+
- The signed-in user has access to the Sheet.
|
|
326
|
+
- OAuth consent includes the Sheets scope.
|
|
327
|
+
- The Sheet id is correct.
|
|
328
|
+
|
|
329
|
+
### The user can sign in but has the wrong permissions
|
|
330
|
+
|
|
331
|
+
Check the `Users` tab:
|
|
332
|
+
|
|
333
|
+
- `email` must match the Google account email.
|
|
334
|
+
- `active` must not be `false`.
|
|
335
|
+
- `role` must be `designer`, `developer`, or `admin`.
|
|
336
|
+
- `projectKey` must be empty or match the provider config.
|
|
337
|
+
|
|
338
|
+
### Feedback does not appear on localhost
|
|
339
|
+
|
|
340
|
+
Check that production and localhost pass the same `projectKey`, the same `contentId`, and normalize to the same path. Use `normalizeUrl` when the routes differ between environments.
|
|
341
|
+
|
|
342
|
+
### Markers do not anchor after DOM changes
|
|
343
|
+
|
|
344
|
+
Prefer stable attributes on important elements:
|
|
345
|
+
|
|
346
|
+
```tsx
|
|
347
|
+
data-review-id="hero-cta"
|
|
348
|
+
data-testid="registration-form"
|
|
349
|
+
```
|
|
350
|
+
|
|
351
|
+
Generated CSS paths work, but they are more likely to break when DOM structure changes.
|
|
352
|
+
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import "./styles.css";
|
|
2
|
+
export { ReviewLensProvider, useReviewLens } from "./review-lens-provider";
|
|
3
|
+
export { ReviewLensOverlay } from "./review-lens-overlay";
|
|
4
|
+
export type { ReviewLensOverlayPlacement } from "./review-lens-overlay";
|
|
5
|
+
export { createGoogleSheetsAdapter } from "./sheets/google-sheets-adapter";
|
|
6
|
+
export { buildElementTarget } from "./selectors/build-element-target";
|
|
7
|
+
export { normalizeReviewUrl } from "./url/normalize-review-url";
|
|
8
|
+
export type { CssSnapshot, CreateFeedbackInput, ElementFingerprint, FeedbackStatus, ReviewLensAdapter, ReviewLensConfig, ReviewLensFeedback, ReviewLensPermission, ReviewLensRole, ReviewLensTarget } from "./types";
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
type ReviewLensOverlayProps = {
|
|
2
|
+
open: boolean;
|
|
3
|
+
onOpenChange?: (open: boolean) => void;
|
|
4
|
+
placement?: ReviewLensOverlayPlacement;
|
|
5
|
+
showResolved?: boolean;
|
|
6
|
+
};
|
|
7
|
+
export type ReviewLensOverlayPlacement = "top-left" | "top-right" | "bottom-left" | "bottom-right";
|
|
8
|
+
export declare function ReviewLensOverlay({ open, onOpenChange, placement, showResolved }: ReviewLensOverlayProps): import("react/jsx-runtime").JSX.Element | null;
|
|
9
|
+
export {};
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { CreateFeedbackInput, ReviewLensAdapter, ReviewLensConfig, ReviewLensFeedback, ReviewLensPermission, ReviewLensProviderProps } from "./types";
|
|
2
|
+
type ReviewLensContextValue = {
|
|
3
|
+
config: ReviewLensConfig;
|
|
4
|
+
adapter: ReviewLensAdapter;
|
|
5
|
+
currentUser?: {
|
|
6
|
+
email: string;
|
|
7
|
+
};
|
|
8
|
+
permissions: ReviewLensPermission[];
|
|
9
|
+
feedback: ReviewLensFeedback[];
|
|
10
|
+
normalizedPath: string;
|
|
11
|
+
refreshFeedback: () => Promise<void>;
|
|
12
|
+
createFeedback: (input: CreateFeedbackInput) => Promise<ReviewLensFeedback>;
|
|
13
|
+
resolveFeedback: (id: string) => Promise<ReviewLensFeedback>;
|
|
14
|
+
};
|
|
15
|
+
export declare function ReviewLensProvider({ config, children }: ReviewLensProviderProps): import("react/jsx-runtime").JSX.Element;
|
|
16
|
+
export declare function useReviewLens(): ReviewLensContextValue;
|
|
17
|
+
export {};
|
|
@@ -0,0 +1,576 @@
|
|
|
1
|
+
import { jsx as c, jsxs as k, Fragment as q } from "react/jsx-runtime";
|
|
2
|
+
import { createContext as D, useMemo as x, useState as E, useCallback as L, useEffect as R, useContext as H } from "react";
|
|
3
|
+
const W = [
|
|
4
|
+
"https://www.googleapis.com/auth/spreadsheets",
|
|
5
|
+
"https://www.googleapis.com/auth/userinfo.email"
|
|
6
|
+
].join(" "), _ = "https://www.googleapis.com/oauth2/v3/userinfo";
|
|
7
|
+
function X(e) {
|
|
8
|
+
const n = e.feedbackSheetName ?? "Feedback", t = e.usersSheetName ?? "Users";
|
|
9
|
+
let r, o;
|
|
10
|
+
async function s() {
|
|
11
|
+
return r ?? (r = Z(e.googleClientId)), r;
|
|
12
|
+
}
|
|
13
|
+
async function v(i, l) {
|
|
14
|
+
const a = await s(), d = await fetch(
|
|
15
|
+
`https://sheets.googleapis.com/v4/spreadsheets/${e.spreadsheetId}${i}`,
|
|
16
|
+
{
|
|
17
|
+
...l,
|
|
18
|
+
headers: {
|
|
19
|
+
Authorization: `Bearer ${a}`,
|
|
20
|
+
"Content-Type": "application/json",
|
|
21
|
+
...l == null ? void 0 : l.headers
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
);
|
|
25
|
+
if (!d.ok)
|
|
26
|
+
throw new Error(`Google Sheets request failed with ${d.status}`);
|
|
27
|
+
return d.json();
|
|
28
|
+
}
|
|
29
|
+
async function b(i) {
|
|
30
|
+
return (await v(
|
|
31
|
+
`/values/${encodeURIComponent(i)}`
|
|
32
|
+
)).values ?? [];
|
|
33
|
+
}
|
|
34
|
+
return {
|
|
35
|
+
async getCurrentUser() {
|
|
36
|
+
if (!o) {
|
|
37
|
+
const i = await s(), l = await fetch(_, {
|
|
38
|
+
headers: { Authorization: `Bearer ${i}` }
|
|
39
|
+
});
|
|
40
|
+
if (!l.ok)
|
|
41
|
+
throw new Error(`Google userinfo request failed with ${l.status}`);
|
|
42
|
+
o = (await l.json()).email;
|
|
43
|
+
}
|
|
44
|
+
if (!o)
|
|
45
|
+
throw new Error("Google account did not return an email address");
|
|
46
|
+
return { email: o };
|
|
47
|
+
},
|
|
48
|
+
async getPermissions(i) {
|
|
49
|
+
const [{ email: l }, a] = await Promise.all([this.getCurrentUser(), b(t)]), d = U(a), y = l.toLowerCase(), u = d.find(
|
|
50
|
+
(f) => {
|
|
51
|
+
var p;
|
|
52
|
+
return ((p = f.email) == null ? void 0 : p.toLowerCase()) === y && f.active !== "false" && (!f.projectKey || f.projectKey === i);
|
|
53
|
+
}
|
|
54
|
+
);
|
|
55
|
+
return V((u == null ? void 0 : u.role) ?? "designer");
|
|
56
|
+
},
|
|
57
|
+
async listFeedback(i) {
|
|
58
|
+
return U(await b(n)).map(T).filter((a) => a !== null).filter(
|
|
59
|
+
(a) => a.projectKey === i.projectKey && a.contentId === i.contentId && a.normalizedPath === i.normalizedPath
|
|
60
|
+
).sort((a, d) => d.createdAt.localeCompare(a.createdAt));
|
|
61
|
+
},
|
|
62
|
+
async createFeedback(i) {
|
|
63
|
+
const l = (/* @__PURE__ */ new Date()).toISOString(), a = {
|
|
64
|
+
...i,
|
|
65
|
+
id: crypto.randomUUID(),
|
|
66
|
+
status: "open",
|
|
67
|
+
createdAt: l,
|
|
68
|
+
updatedAt: l
|
|
69
|
+
};
|
|
70
|
+
return await v(`/values/${encodeURIComponent(n)}:append?valueInputOption=RAW`, {
|
|
71
|
+
method: "POST",
|
|
72
|
+
body: JSON.stringify({ values: [Q(a)] })
|
|
73
|
+
}), a;
|
|
74
|
+
},
|
|
75
|
+
async resolveFeedback(i, l) {
|
|
76
|
+
const a = await b(n), d = a[0] ?? Y, y = d.indexOf("id"), u = d.indexOf("status"), f = d.indexOf("updatedAt"), p = d.indexOf("resolvedAt"), w = d.indexOf("resolvedBy"), m = a.findIndex((N, A) => A > 0 && N[y] === i);
|
|
77
|
+
if (m < 1)
|
|
78
|
+
throw new Error(`Feedback ${i} was not found`);
|
|
79
|
+
const g = [...a[m]], C = (/* @__PURE__ */ new Date()).toISOString();
|
|
80
|
+
g[u] = "resolved", g[f] = C, g[p] = C, g[w] = l, await v(
|
|
81
|
+
`/values/${encodeURIComponent(n)}!A${m + 1}:Q${m + 1}?valueInputOption=RAW`,
|
|
82
|
+
{
|
|
83
|
+
method: "PUT",
|
|
84
|
+
body: JSON.stringify({ values: [g] })
|
|
85
|
+
}
|
|
86
|
+
);
|
|
87
|
+
const $ = T(M(d, g));
|
|
88
|
+
if (!$)
|
|
89
|
+
throw new Error(`Feedback ${i} could not be parsed after resolving`);
|
|
90
|
+
return $;
|
|
91
|
+
}
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
const Y = [
|
|
95
|
+
"id",
|
|
96
|
+
"projectKey",
|
|
97
|
+
"contentId",
|
|
98
|
+
"normalizedPath",
|
|
99
|
+
"originalUrl",
|
|
100
|
+
"selector",
|
|
101
|
+
"selectorStrategy",
|
|
102
|
+
"elementFingerprintJson",
|
|
103
|
+
"cssSnapshotJson",
|
|
104
|
+
"comment",
|
|
105
|
+
"status",
|
|
106
|
+
"authorEmail",
|
|
107
|
+
"createdAt",
|
|
108
|
+
"updatedAt",
|
|
109
|
+
"resolvedAt",
|
|
110
|
+
"resolvedBy"
|
|
111
|
+
];
|
|
112
|
+
function Q(e) {
|
|
113
|
+
return [
|
|
114
|
+
e.id,
|
|
115
|
+
e.projectKey,
|
|
116
|
+
e.contentId,
|
|
117
|
+
e.normalizedPath,
|
|
118
|
+
e.originalUrl,
|
|
119
|
+
e.selector,
|
|
120
|
+
e.selectorStrategy,
|
|
121
|
+
JSON.stringify(e.elementFingerprint),
|
|
122
|
+
JSON.stringify(e.cssSnapshot),
|
|
123
|
+
e.comment,
|
|
124
|
+
e.status,
|
|
125
|
+
e.authorEmail,
|
|
126
|
+
e.createdAt,
|
|
127
|
+
e.updatedAt,
|
|
128
|
+
e.resolvedAt ?? "",
|
|
129
|
+
e.resolvedBy ?? ""
|
|
130
|
+
];
|
|
131
|
+
}
|
|
132
|
+
function U(e) {
|
|
133
|
+
const [n, ...t] = e;
|
|
134
|
+
return n ? t.map((r) => M(n, r)) : [];
|
|
135
|
+
}
|
|
136
|
+
function M(e, n) {
|
|
137
|
+
return Object.fromEntries(e.map((t, r) => [t, n[r] ?? ""]));
|
|
138
|
+
}
|
|
139
|
+
function T(e) {
|
|
140
|
+
return e.id ? {
|
|
141
|
+
id: e.id,
|
|
142
|
+
projectKey: e.projectKey,
|
|
143
|
+
contentId: e.contentId,
|
|
144
|
+
normalizedPath: e.normalizedPath,
|
|
145
|
+
originalUrl: e.originalUrl,
|
|
146
|
+
selector: e.selector,
|
|
147
|
+
selectorStrategy: e.selectorStrategy === "stable-attribute" ? "stable-attribute" : "css-path",
|
|
148
|
+
elementFingerprint: O(e.elementFingerprintJson, {
|
|
149
|
+
tagName: "",
|
|
150
|
+
width: 0,
|
|
151
|
+
height: 0
|
|
152
|
+
}),
|
|
153
|
+
cssSnapshot: O(e.cssSnapshotJson, {
|
|
154
|
+
margin: "",
|
|
155
|
+
padding: "",
|
|
156
|
+
border: "",
|
|
157
|
+
fontFamily: "",
|
|
158
|
+
fontSize: "",
|
|
159
|
+
lineHeight: "",
|
|
160
|
+
color: "",
|
|
161
|
+
backgroundColor: "",
|
|
162
|
+
width: 0,
|
|
163
|
+
height: 0
|
|
164
|
+
}),
|
|
165
|
+
comment: e.comment,
|
|
166
|
+
status: e.status === "resolved" ? "resolved" : "open",
|
|
167
|
+
authorEmail: e.authorEmail,
|
|
168
|
+
createdAt: e.createdAt,
|
|
169
|
+
updatedAt: e.updatedAt,
|
|
170
|
+
resolvedAt: e.resolvedAt || void 0,
|
|
171
|
+
resolvedBy: e.resolvedBy || void 0
|
|
172
|
+
} : null;
|
|
173
|
+
}
|
|
174
|
+
function O(e, n) {
|
|
175
|
+
try {
|
|
176
|
+
return e ? JSON.parse(e) : n;
|
|
177
|
+
} catch {
|
|
178
|
+
return n;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
function V(e) {
|
|
182
|
+
return e === "admin" ? ["create", "read", "resolve"] : e === "developer" ? ["read", "resolve"] : ["create", "read"];
|
|
183
|
+
}
|
|
184
|
+
async function Z(e) {
|
|
185
|
+
return await ee(), new Promise((n, t) => {
|
|
186
|
+
var o;
|
|
187
|
+
const r = (o = window.google) == null ? void 0 : o.accounts.oauth2.initTokenClient({
|
|
188
|
+
client_id: e,
|
|
189
|
+
scope: W,
|
|
190
|
+
callback: (s) => {
|
|
191
|
+
if (s.error || !s.access_token) {
|
|
192
|
+
t(new Error(s.error ?? "Google OAuth did not return an access token"));
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
n(s.access_token);
|
|
196
|
+
}
|
|
197
|
+
});
|
|
198
|
+
r == null || r.requestAccessToken({ prompt: "" });
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
function ee() {
|
|
202
|
+
var e;
|
|
203
|
+
return (e = window.google) != null && e.accounts.oauth2 ? Promise.resolve() : new Promise((n, t) => {
|
|
204
|
+
const r = document.querySelector(
|
|
205
|
+
'script[src="https://accounts.google.com/gsi/client"]'
|
|
206
|
+
);
|
|
207
|
+
if (r) {
|
|
208
|
+
r.addEventListener("load", () => n(), { once: !0 }), r.addEventListener("error", () => t(new Error("Google Identity failed to load")), {
|
|
209
|
+
once: !0
|
|
210
|
+
});
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
const o = document.createElement("script");
|
|
214
|
+
o.src = "https://accounts.google.com/gsi/client", o.async = !0, o.defer = !0, o.onload = () => n(), o.onerror = () => t(new Error("Google Identity failed to load")), document.head.append(o);
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
function te(e) {
|
|
218
|
+
return new URL(e, window.location.href).pathname.replace(/\/+$/, "") || "/";
|
|
219
|
+
}
|
|
220
|
+
const J = D(null);
|
|
221
|
+
function me({ config: e, children: n }) {
|
|
222
|
+
const t = x(() => e.adapter ? e.adapter : X({
|
|
223
|
+
googleClientId: z(e.googleClientId, "googleClientId"),
|
|
224
|
+
spreadsheetId: z(e.spreadsheetId, "spreadsheetId"),
|
|
225
|
+
feedbackSheetName: e.sheetName ?? "Feedback"
|
|
226
|
+
}), [e.adapter, e.googleClientId, e.sheetName, e.spreadsheetId]), r = e.currentUrl ?? window.location.href, o = (e.normalizeUrl ?? te)(r), [s, v] = E(), [b, i] = E([]), [l, a] = E([]), d = L(async () => {
|
|
227
|
+
const p = await t.listFeedback({
|
|
228
|
+
projectKey: e.projectKey,
|
|
229
|
+
contentId: e.contentId,
|
|
230
|
+
normalizedPath: o
|
|
231
|
+
});
|
|
232
|
+
a(p);
|
|
233
|
+
}, [t, e.contentId, e.projectKey, o]);
|
|
234
|
+
R(() => {
|
|
235
|
+
let p = !0;
|
|
236
|
+
async function w() {
|
|
237
|
+
const [m, g] = await Promise.all([
|
|
238
|
+
t.getCurrentUser(),
|
|
239
|
+
t.getPermissions(e.projectKey)
|
|
240
|
+
]);
|
|
241
|
+
p && (v(m), i(g), await d());
|
|
242
|
+
}
|
|
243
|
+
return w(), () => {
|
|
244
|
+
p = !1;
|
|
245
|
+
};
|
|
246
|
+
}, [t, e.projectKey, d]);
|
|
247
|
+
const y = L(
|
|
248
|
+
async (p) => {
|
|
249
|
+
const w = await t.createFeedback(p);
|
|
250
|
+
return a((m) => [w, ...m]), w;
|
|
251
|
+
},
|
|
252
|
+
[t]
|
|
253
|
+
), u = L(
|
|
254
|
+
async (p) => {
|
|
255
|
+
const w = await t.resolveFeedback(p, (s == null ? void 0 : s.email) ?? "");
|
|
256
|
+
return a(
|
|
257
|
+
(m) => m.map((g) => g.id === p ? w : g)
|
|
258
|
+
), w;
|
|
259
|
+
},
|
|
260
|
+
[t, s == null ? void 0 : s.email]
|
|
261
|
+
), f = x(
|
|
262
|
+
() => ({
|
|
263
|
+
config: e,
|
|
264
|
+
adapter: t,
|
|
265
|
+
currentUser: s,
|
|
266
|
+
permissions: b,
|
|
267
|
+
feedback: l,
|
|
268
|
+
normalizedPath: o,
|
|
269
|
+
refreshFeedback: d,
|
|
270
|
+
createFeedback: y,
|
|
271
|
+
resolveFeedback: u
|
|
272
|
+
}),
|
|
273
|
+
[
|
|
274
|
+
t,
|
|
275
|
+
e,
|
|
276
|
+
y,
|
|
277
|
+
s,
|
|
278
|
+
l,
|
|
279
|
+
o,
|
|
280
|
+
b,
|
|
281
|
+
d,
|
|
282
|
+
u
|
|
283
|
+
]
|
|
284
|
+
);
|
|
285
|
+
return /* @__PURE__ */ c(J.Provider, { value: f, children: n });
|
|
286
|
+
}
|
|
287
|
+
function ne() {
|
|
288
|
+
const e = H(J);
|
|
289
|
+
if (!e)
|
|
290
|
+
throw new Error("useReviewLens must be used inside ReviewLensProvider");
|
|
291
|
+
return e;
|
|
292
|
+
}
|
|
293
|
+
function z(e, n) {
|
|
294
|
+
if (!e)
|
|
295
|
+
throw new Error(`review-lens-react requires config.${n} when no adapter is provided`);
|
|
296
|
+
return e;
|
|
297
|
+
}
|
|
298
|
+
const re = [
|
|
299
|
+
"data-review-id",
|
|
300
|
+
"data-testid",
|
|
301
|
+
"data-test-id",
|
|
302
|
+
"aria-label",
|
|
303
|
+
"name"
|
|
304
|
+
];
|
|
305
|
+
function B(e) {
|
|
306
|
+
const n = e.getBoundingClientRect(), t = oe(e);
|
|
307
|
+
return {
|
|
308
|
+
selector: t.selector,
|
|
309
|
+
selectorStrategy: t.strategy,
|
|
310
|
+
fingerprint: ae(e, n),
|
|
311
|
+
cssSnapshot: ie(e, n),
|
|
312
|
+
rect: n
|
|
313
|
+
};
|
|
314
|
+
}
|
|
315
|
+
function oe(e) {
|
|
316
|
+
for (const n of re) {
|
|
317
|
+
const t = e.getAttribute(n);
|
|
318
|
+
if (t)
|
|
319
|
+
return {
|
|
320
|
+
selector: `[${n}="${K(t)}"]`,
|
|
321
|
+
strategy: "stable-attribute"
|
|
322
|
+
};
|
|
323
|
+
}
|
|
324
|
+
return e.id ? { selector: `#${K(e.id)}`, strategy: "stable-attribute" } : { selector: se(e), strategy: "css-path" };
|
|
325
|
+
}
|
|
326
|
+
function se(e) {
|
|
327
|
+
const n = [];
|
|
328
|
+
let t = e;
|
|
329
|
+
for (; t && t.nodeType === Node.ELEMENT_NODE && t !== document.body; ) {
|
|
330
|
+
const r = t.parentElement, o = t.tagName.toLowerCase();
|
|
331
|
+
if (!r) {
|
|
332
|
+
n.unshift(o);
|
|
333
|
+
break;
|
|
334
|
+
}
|
|
335
|
+
const s = t.tagName, v = Array.from(r.children).filter(
|
|
336
|
+
(i) => i.tagName === s
|
|
337
|
+
), b = v.indexOf(t) + 1;
|
|
338
|
+
n.unshift(v.length > 1 ? `${o}:nth-of-type(${b})` : o), t = r;
|
|
339
|
+
}
|
|
340
|
+
return n.join(" > ");
|
|
341
|
+
}
|
|
342
|
+
function ae(e, n) {
|
|
343
|
+
var t;
|
|
344
|
+
return {
|
|
345
|
+
tagName: e.tagName.toLowerCase(),
|
|
346
|
+
id: e.id || void 0,
|
|
347
|
+
className: e.getAttribute("class") || void 0,
|
|
348
|
+
textSnippet: ((t = e.textContent) == null ? void 0 : t.trim().slice(0, 80)) || void 0,
|
|
349
|
+
ariaLabel: e.getAttribute("aria-label") || void 0,
|
|
350
|
+
width: Math.round(n.width),
|
|
351
|
+
height: Math.round(n.height)
|
|
352
|
+
};
|
|
353
|
+
}
|
|
354
|
+
function ie(e, n) {
|
|
355
|
+
const t = window.getComputedStyle(e);
|
|
356
|
+
return {
|
|
357
|
+
margin: j(t.marginTop, t.marginRight, t.marginBottom, t.marginLeft),
|
|
358
|
+
padding: j(
|
|
359
|
+
t.paddingTop,
|
|
360
|
+
t.paddingRight,
|
|
361
|
+
t.paddingBottom,
|
|
362
|
+
t.paddingLeft
|
|
363
|
+
),
|
|
364
|
+
border: j(
|
|
365
|
+
t.borderTopWidth,
|
|
366
|
+
t.borderRightWidth,
|
|
367
|
+
t.borderBottomWidth,
|
|
368
|
+
t.borderLeftWidth
|
|
369
|
+
),
|
|
370
|
+
fontFamily: t.fontFamily,
|
|
371
|
+
fontSize: t.fontSize,
|
|
372
|
+
lineHeight: t.lineHeight,
|
|
373
|
+
color: t.color,
|
|
374
|
+
backgroundColor: t.backgroundColor,
|
|
375
|
+
width: Math.round(n.width),
|
|
376
|
+
height: Math.round(n.height)
|
|
377
|
+
};
|
|
378
|
+
}
|
|
379
|
+
function j(e, n, t, r) {
|
|
380
|
+
return e === n && n === t && t === r ? e : `${e} ${n} ${t} ${r}`;
|
|
381
|
+
}
|
|
382
|
+
function K(e) {
|
|
383
|
+
return typeof CSS < "u" && typeof CSS.escape == "function" ? CSS.escape(e) : e.replace(/["\\]/g, "\\$&");
|
|
384
|
+
}
|
|
385
|
+
function fe({
|
|
386
|
+
open: e,
|
|
387
|
+
onOpenChange: n,
|
|
388
|
+
placement: t = "top-right",
|
|
389
|
+
showResolved: r = !1
|
|
390
|
+
}) {
|
|
391
|
+
const {
|
|
392
|
+
config: o,
|
|
393
|
+
currentUser: s,
|
|
394
|
+
feedback: v,
|
|
395
|
+
normalizedPath: b,
|
|
396
|
+
permissions: i,
|
|
397
|
+
createFeedback: l,
|
|
398
|
+
resolveFeedback: a
|
|
399
|
+
} = ne(), [d, y] = E(), [u, f] = E(), [p, w] = E(""), [m, g] = E(), C = i.includes("create"), $ = i.includes("resolve"), N = x(
|
|
400
|
+
() => v.filter((h) => r || h.status !== "resolved"),
|
|
401
|
+
[v, r]
|
|
402
|
+
);
|
|
403
|
+
R(() => {
|
|
404
|
+
e || (y(void 0), f(void 0), w(""));
|
|
405
|
+
}, [e]);
|
|
406
|
+
const A = L((h) => {
|
|
407
|
+
const I = h.target instanceof Element ? h.target : null;
|
|
408
|
+
if (I)
|
|
409
|
+
return I.closest("[data-review-lens-ui]") ? null : I;
|
|
410
|
+
const S = document.elementFromPoint(h.clientX, h.clientY);
|
|
411
|
+
return !S || S.closest("[data-review-lens-ui]") ? null : S;
|
|
412
|
+
}, []);
|
|
413
|
+
if (R(() => {
|
|
414
|
+
if (!e || u)
|
|
415
|
+
return;
|
|
416
|
+
function h(S) {
|
|
417
|
+
const F = A(S);
|
|
418
|
+
F && y(B(F));
|
|
419
|
+
}
|
|
420
|
+
function I(S) {
|
|
421
|
+
const F = A(S);
|
|
422
|
+
F && (S.preventDefault(), S.stopPropagation(), f(B(F)));
|
|
423
|
+
}
|
|
424
|
+
return window.addEventListener("mousemove", h, !0), window.addEventListener("click", I, !0), () => {
|
|
425
|
+
window.removeEventListener("mousemove", h, !0), window.removeEventListener("click", I, !0);
|
|
426
|
+
};
|
|
427
|
+
}, [A, u, e]), !e)
|
|
428
|
+
return null;
|
|
429
|
+
const P = u ?? d;
|
|
430
|
+
async function G() {
|
|
431
|
+
!u || !p.trim() || !s || !C || (await l({
|
|
432
|
+
projectKey: o.projectKey,
|
|
433
|
+
contentId: o.contentId,
|
|
434
|
+
normalizedPath: b,
|
|
435
|
+
originalUrl: o.currentUrl ?? window.location.href,
|
|
436
|
+
selector: u.selector,
|
|
437
|
+
selectorStrategy: u.selectorStrategy,
|
|
438
|
+
elementFingerprint: u.fingerprint,
|
|
439
|
+
cssSnapshot: u.cssSnapshot,
|
|
440
|
+
comment: p.trim(),
|
|
441
|
+
authorEmail: s.email
|
|
442
|
+
}), w(""), f(void 0));
|
|
443
|
+
}
|
|
444
|
+
return /* @__PURE__ */ k("div", { className: "review-lens-root", "data-review-lens-ui": !0, children: [
|
|
445
|
+
P ? /* @__PURE__ */ c(ce, { target: P, locked: !!u }) : null,
|
|
446
|
+
/* @__PURE__ */ c(
|
|
447
|
+
le,
|
|
448
|
+
{
|
|
449
|
+
feedback: N,
|
|
450
|
+
selectedFeedback: m,
|
|
451
|
+
onSelect: g
|
|
452
|
+
}
|
|
453
|
+
),
|
|
454
|
+
/* @__PURE__ */ k("aside", { className: `review-lens-panel review-lens-panel--${t}`, "data-review-lens-ui": !0, children: [
|
|
455
|
+
/* @__PURE__ */ k("header", { className: "review-lens-panel__header", children: [
|
|
456
|
+
/* @__PURE__ */ k("div", { children: [
|
|
457
|
+
/* @__PURE__ */ c("p", { className: "review-lens-kicker", children: "Review Lens" }),
|
|
458
|
+
/* @__PURE__ */ c("h2", { children: u ? "Element locked" : "Inspecting" })
|
|
459
|
+
] }),
|
|
460
|
+
/* @__PURE__ */ c("button", { type: "button", onClick: () => n == null ? void 0 : n(!1), children: "Close" })
|
|
461
|
+
] }),
|
|
462
|
+
P ? /* @__PURE__ */ c(de, { target: P }) : /* @__PURE__ */ c("p", { children: "Move over the app to inspect." }),
|
|
463
|
+
u ? /* @__PURE__ */ k(
|
|
464
|
+
"form",
|
|
465
|
+
{
|
|
466
|
+
className: "review-lens-feedback-form",
|
|
467
|
+
onSubmit: (h) => {
|
|
468
|
+
h.preventDefault(), G();
|
|
469
|
+
},
|
|
470
|
+
children: [
|
|
471
|
+
/* @__PURE__ */ c("label", { htmlFor: "review-lens-comment", children: "Feedback" }),
|
|
472
|
+
/* @__PURE__ */ c(
|
|
473
|
+
"textarea",
|
|
474
|
+
{
|
|
475
|
+
id: "review-lens-comment",
|
|
476
|
+
value: p,
|
|
477
|
+
disabled: !C,
|
|
478
|
+
onChange: (h) => w(h.target.value),
|
|
479
|
+
placeholder: C ? "Describe the UX issue..." : "You do not have permission to comment."
|
|
480
|
+
}
|
|
481
|
+
),
|
|
482
|
+
/* @__PURE__ */ k("div", { className: "review-lens-actions", children: [
|
|
483
|
+
/* @__PURE__ */ c("button", { type: "button", onClick: () => f(void 0), children: "Unlock" }),
|
|
484
|
+
/* @__PURE__ */ c("button", { type: "submit", disabled: !p.trim() || !C, children: "Save feedback" })
|
|
485
|
+
] })
|
|
486
|
+
]
|
|
487
|
+
}
|
|
488
|
+
) : null,
|
|
489
|
+
/* @__PURE__ */ k("section", { className: "review-lens-comments", children: [
|
|
490
|
+
/* @__PURE__ */ c("h3", { children: "Page feedback" }),
|
|
491
|
+
N.length === 0 ? /* @__PURE__ */ c("p", { children: "No feedback for this view." }) : null,
|
|
492
|
+
N.map((h) => /* @__PURE__ */ k(
|
|
493
|
+
"article",
|
|
494
|
+
{
|
|
495
|
+
className: (m == null ? void 0 : m.id) === h.id ? "review-lens-comment review-lens-comment--selected" : "review-lens-comment",
|
|
496
|
+
children: [
|
|
497
|
+
/* @__PURE__ */ c("p", { children: h.comment }),
|
|
498
|
+
/* @__PURE__ */ c("span", { children: h.authorEmail }),
|
|
499
|
+
h.status === "open" && $ ? /* @__PURE__ */ c("button", { type: "button", onClick: () => void a(h.id), children: "Resolve" }) : null
|
|
500
|
+
]
|
|
501
|
+
},
|
|
502
|
+
h.id
|
|
503
|
+
))
|
|
504
|
+
] })
|
|
505
|
+
] })
|
|
506
|
+
] });
|
|
507
|
+
}
|
|
508
|
+
function ce({ target: e, locked: n }) {
|
|
509
|
+
return /* @__PURE__ */ c(
|
|
510
|
+
"div",
|
|
511
|
+
{
|
|
512
|
+
className: n ? "review-lens-highlight review-lens-highlight--locked" : "review-lens-highlight",
|
|
513
|
+
style: {
|
|
514
|
+
top: e.rect.top + window.scrollY,
|
|
515
|
+
left: e.rect.left + window.scrollX,
|
|
516
|
+
width: e.rect.width,
|
|
517
|
+
height: e.rect.height
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
);
|
|
521
|
+
}
|
|
522
|
+
function le({
|
|
523
|
+
feedback: e,
|
|
524
|
+
selectedFeedback: n,
|
|
525
|
+
onSelect: t
|
|
526
|
+
}) {
|
|
527
|
+
return /* @__PURE__ */ c(q, { children: e.map((r) => {
|
|
528
|
+
const o = ue(r.selector), s = o == null ? void 0 : o.getBoundingClientRect();
|
|
529
|
+
return s ? /* @__PURE__ */ c(
|
|
530
|
+
"button",
|
|
531
|
+
{
|
|
532
|
+
type: "button",
|
|
533
|
+
className: (n == null ? void 0 : n.id) === r.id ? "review-lens-marker review-lens-marker--selected" : "review-lens-marker",
|
|
534
|
+
style: {
|
|
535
|
+
top: s.top + window.scrollY,
|
|
536
|
+
left: s.left + window.scrollX + s.width
|
|
537
|
+
},
|
|
538
|
+
onClick: () => t(r),
|
|
539
|
+
"aria-label": `Open feedback from ${r.authorEmail}`
|
|
540
|
+
},
|
|
541
|
+
r.id
|
|
542
|
+
) : null;
|
|
543
|
+
}) });
|
|
544
|
+
}
|
|
545
|
+
function de({ target: e }) {
|
|
546
|
+
const n = [
|
|
547
|
+
["Selector", e.selector],
|
|
548
|
+
["Size", `${e.cssSnapshot.width} x ${e.cssSnapshot.height}`],
|
|
549
|
+
["Margin", e.cssSnapshot.margin],
|
|
550
|
+
["Padding", e.cssSnapshot.padding],
|
|
551
|
+
["Border", e.cssSnapshot.border],
|
|
552
|
+
["Font", `${e.cssSnapshot.fontSize} / ${e.cssSnapshot.lineHeight}`],
|
|
553
|
+
["Family", e.cssSnapshot.fontFamily],
|
|
554
|
+
["Color", e.cssSnapshot.color],
|
|
555
|
+
["Background", e.cssSnapshot.backgroundColor]
|
|
556
|
+
];
|
|
557
|
+
return /* @__PURE__ */ c("dl", { className: "review-lens-metrics", children: n.map(([t, r]) => /* @__PURE__ */ k("div", { children: [
|
|
558
|
+
/* @__PURE__ */ c("dt", { children: t }),
|
|
559
|
+
/* @__PURE__ */ c("dd", { children: r })
|
|
560
|
+
] }, t)) });
|
|
561
|
+
}
|
|
562
|
+
function ue(e) {
|
|
563
|
+
try {
|
|
564
|
+
return document.querySelector(e);
|
|
565
|
+
} catch {
|
|
566
|
+
return null;
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
export {
|
|
570
|
+
fe as ReviewLensOverlay,
|
|
571
|
+
me as ReviewLensProvider,
|
|
572
|
+
B as buildElementTarget,
|
|
573
|
+
X as createGoogleSheetsAdapter,
|
|
574
|
+
te as normalizeReviewUrl,
|
|
575
|
+
ne as useReviewLens
|
|
576
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
(function(w,r){typeof exports=="object"&&typeof module<"u"?r(exports,require("react/jsx-runtime"),require("react")):typeof define=="function"&&define.amd?define(["exports","react/jsx-runtime","react"],r):(w=typeof globalThis<"u"?globalThis:w||self,r(w.ReviewLensReact={},w.jsxRuntime,w.React))})(this,(function(w,r,p){"use strict";const J=["https://www.googleapis.com/auth/spreadsheets","https://www.googleapis.com/auth/userinfo.email"].join(" "),D="https://www.googleapis.com/oauth2/v3/userinfo";function T(e){const n=e.feedbackSheetName??"Feedback",t=e.usersSheetName??"Users";let o,s;async function a(){return o??(o=X(e.googleClientId)),o}async function y(c,l){const i=await a(),d=await fetch(`https://sheets.googleapis.com/v4/spreadsheets/${e.spreadsheetId}${c}`,{...l,headers:{Authorization:`Bearer ${i}`,"Content-Type":"application/json",...l==null?void 0:l.headers}});if(!d.ok)throw new Error(`Google Sheets request failed with ${d.status}`);return d.json()}async function S(c){return(await y(`/values/${encodeURIComponent(c)}`)).values??[]}return{async getCurrentUser(){if(!s){const c=await a(),l=await fetch(D,{headers:{Authorization:`Bearer ${c}`}});if(!l.ok)throw new Error(`Google userinfo request failed with ${l.status}`);s=(await l.json()).email}if(!s)throw new Error("Google account did not return an email address");return{email:s}},async getPermissions(c){const[{email:l},i]=await Promise.all([this.getCurrentUser(),S(t)]),d=U(i),k=l.toLowerCase(),u=d.find(g=>{var f;return((f=g.email)==null?void 0:f.toLowerCase())===k&&g.active!=="false"&&(!g.projectKey||g.projectKey===c)});return _((u==null?void 0:u.role)??"designer")},async listFeedback(c){return U(await S(n)).map(x).filter(i=>i!==null).filter(i=>i.projectKey===c.projectKey&&i.contentId===c.contentId&&i.normalizedPath===c.normalizedPath).sort((i,d)=>d.createdAt.localeCompare(i.createdAt))},async createFeedback(c){const l=new Date().toISOString(),i={...c,id:crypto.randomUUID(),status:"open",createdAt:l,updatedAt:l};return await y(`/values/${encodeURIComponent(n)}:append?valueInputOption=RAW`,{method:"POST",body:JSON.stringify({values:[W(i)]})}),i},async resolveFeedback(c,l){const i=await S(n),d=i[0]??H,k=d.indexOf("id"),u=d.indexOf("status"),g=d.indexOf("updatedAt"),f=d.indexOf("resolvedAt"),v=d.indexOf("resolvedBy"),m=i.findIndex((A,N)=>N>0&&A[k]===c);if(m<1)throw new Error(`Feedback ${c} was not found`);const b=[...i[m]],E=new Date().toISOString();b[u]="resolved",b[g]=E,b[f]=E,b[v]=l,await y(`/values/${encodeURIComponent(n)}!A${m+1}:Q${m+1}?valueInputOption=RAW`,{method:"PUT",body:JSON.stringify({values:[b]})});const P=x(O(d,b));if(!P)throw new Error(`Feedback ${c} could not be parsed after resolving`);return P}}}const H=["id","projectKey","contentId","normalizedPath","originalUrl","selector","selectorStrategy","elementFingerprintJson","cssSnapshotJson","comment","status","authorEmail","createdAt","updatedAt","resolvedAt","resolvedBy"];function W(e){return[e.id,e.projectKey,e.contentId,e.normalizedPath,e.originalUrl,e.selector,e.selectorStrategy,JSON.stringify(e.elementFingerprint),JSON.stringify(e.cssSnapshot),e.comment,e.status,e.authorEmail,e.createdAt,e.updatedAt,e.resolvedAt??"",e.resolvedBy??""]}function U(e){const[n,...t]=e;return n?t.map(o=>O(n,o)):[]}function O(e,n){return Object.fromEntries(e.map((t,o)=>[t,n[o]??""]))}function x(e){return e.id?{id:e.id,projectKey:e.projectKey,contentId:e.contentId,normalizedPath:e.normalizedPath,originalUrl:e.originalUrl,selector:e.selector,selectorStrategy:e.selectorStrategy==="stable-attribute"?"stable-attribute":"css-path",elementFingerprint:z(e.elementFingerprintJson,{tagName:"",width:0,height:0}),cssSnapshot:z(e.cssSnapshotJson,{margin:"",padding:"",border:"",fontFamily:"",fontSize:"",lineHeight:"",color:"",backgroundColor:"",width:0,height:0}),comment:e.comment,status:e.status==="resolved"?"resolved":"open",authorEmail:e.authorEmail,createdAt:e.createdAt,updatedAt:e.updatedAt,resolvedAt:e.resolvedAt||void 0,resolvedBy:e.resolvedBy||void 0}:null}function z(e,n){try{return e?JSON.parse(e):n}catch{return n}}function _(e){return e==="admin"?["create","read","resolve"]:e==="developer"?["read","resolve"]:["create","read"]}async function X(e){return await Y(),new Promise((n,t)=>{var s;const o=(s=window.google)==null?void 0:s.accounts.oauth2.initTokenClient({client_id:e,scope:J,callback:a=>{if(a.error||!a.access_token){t(new Error(a.error??"Google OAuth did not return an access token"));return}n(a.access_token)}});o==null||o.requestAccessToken({prompt:""})})}function Y(){var e;return(e=window.google)!=null&&e.accounts.oauth2?Promise.resolve():new Promise((n,t)=>{const o=document.querySelector('script[src="https://accounts.google.com/gsi/client"]');if(o){o.addEventListener("load",()=>n(),{once:!0}),o.addEventListener("error",()=>t(new Error("Google Identity failed to load")),{once:!0});return}const s=document.createElement("script");s.src="https://accounts.google.com/gsi/client",s.async=!0,s.defer=!0,s.onload=()=>n(),s.onerror=()=>t(new Error("Google Identity failed to load")),document.head.append(s)})}function B(e){return new URL(e,window.location.href).pathname.replace(/\/+$/,"")||"/"}const K=p.createContext(null);function Q({config:e,children:n}){const t=p.useMemo(()=>e.adapter?e.adapter:T({googleClientId:q(e.googleClientId,"googleClientId"),spreadsheetId:q(e.spreadsheetId,"spreadsheetId"),feedbackSheetName:e.sheetName??"Feedback"}),[e.adapter,e.googleClientId,e.sheetName,e.spreadsheetId]),o=e.currentUrl??window.location.href,s=(e.normalizeUrl??B)(o),[a,y]=p.useState(),[S,c]=p.useState([]),[l,i]=p.useState([]),d=p.useCallback(async()=>{const f=await t.listFeedback({projectKey:e.projectKey,contentId:e.contentId,normalizedPath:s});i(f)},[t,e.contentId,e.projectKey,s]);p.useEffect(()=>{let f=!0;async function v(){const[m,b]=await Promise.all([t.getCurrentUser(),t.getPermissions(e.projectKey)]);f&&(y(m),c(b),await d())}return v(),()=>{f=!1}},[t,e.projectKey,d]);const k=p.useCallback(async f=>{const v=await t.createFeedback(f);return i(m=>[v,...m]),v},[t]),u=p.useCallback(async f=>{const v=await t.resolveFeedback(f,(a==null?void 0:a.email)??"");return i(m=>m.map(b=>b.id===f?v:b)),v},[t,a==null?void 0:a.email]),g=p.useMemo(()=>({config:e,adapter:t,currentUser:a,permissions:S,feedback:l,normalizedPath:s,refreshFeedback:d,createFeedback:k,resolveFeedback:u}),[t,e,k,a,l,s,S,d,u]);return r.jsx(K.Provider,{value:g,children:n})}function M(){const e=p.useContext(K);if(!e)throw new Error("useReviewLens must be used inside ReviewLensProvider");return e}function q(e,n){if(!e)throw new Error(`review-lens-react requires config.${n} when no adapter is provided`);return e}const V=["data-review-id","data-testid","data-test-id","aria-label","name"];function L(e){const n=e.getBoundingClientRect(),t=Z(e);return{selector:t.selector,selectorStrategy:t.strategy,fingerprint:ee(e,n),cssSnapshot:te(e,n),rect:n}}function Z(e){for(const n of V){const t=e.getAttribute(n);if(t)return{selector:`[${n}="${G(t)}"]`,strategy:"stable-attribute"}}return e.id?{selector:`#${G(e.id)}`,strategy:"stable-attribute"}:{selector:R(e),strategy:"css-path"}}function R(e){const n=[];let t=e;for(;t&&t.nodeType===Node.ELEMENT_NODE&&t!==document.body;){const o=t.parentElement,s=t.tagName.toLowerCase();if(!o){n.unshift(s);break}const a=t.tagName,y=Array.from(o.children).filter(c=>c.tagName===a),S=y.indexOf(t)+1;n.unshift(y.length>1?`${s}:nth-of-type(${S})`:s),t=o}return n.join(" > ")}function ee(e,n){var t;return{tagName:e.tagName.toLowerCase(),id:e.id||void 0,className:e.getAttribute("class")||void 0,textSnippet:((t=e.textContent)==null?void 0:t.trim().slice(0,80))||void 0,ariaLabel:e.getAttribute("aria-label")||void 0,width:Math.round(n.width),height:Math.round(n.height)}}function te(e,n){const t=window.getComputedStyle(e);return{margin:j(t.marginTop,t.marginRight,t.marginBottom,t.marginLeft),padding:j(t.paddingTop,t.paddingRight,t.paddingBottom,t.paddingLeft),border:j(t.borderTopWidth,t.borderRightWidth,t.borderBottomWidth,t.borderLeftWidth),fontFamily:t.fontFamily,fontSize:t.fontSize,lineHeight:t.lineHeight,color:t.color,backgroundColor:t.backgroundColor,width:Math.round(n.width),height:Math.round(n.height)}}function j(e,n,t,o){return e===n&&n===t&&t===o?e:`${e} ${n} ${t} ${o}`}function G(e){return typeof CSS<"u"&&typeof CSS.escape=="function"?CSS.escape(e):e.replace(/["\\]/g,"\\$&")}function ne({open:e,onOpenChange:n,placement:t="top-right",showResolved:o=!1}){const{config:s,currentUser:a,feedback:y,normalizedPath:S,permissions:c,createFeedback:l,resolveFeedback:i}=M(),[d,k]=p.useState(),[u,g]=p.useState(),[f,v]=p.useState(""),[m,b]=p.useState(),E=c.includes("create"),P=c.includes("resolve"),A=p.useMemo(()=>y.filter(h=>o||h.status!=="resolved"),[y,o]);p.useEffect(()=>{e||(k(void 0),g(void 0),v(""))},[e]);const N=p.useCallback(h=>{const I=h.target instanceof Element?h.target:null;if(I)return I.closest("[data-review-lens-ui]")?null:I;const C=document.elementFromPoint(h.clientX,h.clientY);return!C||C.closest("[data-review-lens-ui]")?null:C},[]);if(p.useEffect(()=>{if(!e||u)return;function h(C){const F=N(C);F&&k(L(F))}function I(C){const F=N(C);F&&(C.preventDefault(),C.stopPropagation(),g(L(F)))}return window.addEventListener("mousemove",h,!0),window.addEventListener("click",I,!0),()=>{window.removeEventListener("mousemove",h,!0),window.removeEventListener("click",I,!0)}},[N,u,e]),!e)return null;const $=u??d;async function ie(){!u||!f.trim()||!a||!E||(await l({projectKey:s.projectKey,contentId:s.contentId,normalizedPath:S,originalUrl:s.currentUrl??window.location.href,selector:u.selector,selectorStrategy:u.selectorStrategy,elementFingerprint:u.fingerprint,cssSnapshot:u.cssSnapshot,comment:f.trim(),authorEmail:a.email}),v(""),g(void 0))}return r.jsxs("div",{className:"review-lens-root","data-review-lens-ui":!0,children:[$?r.jsx(re,{target:$,locked:!!u}):null,r.jsx(oe,{feedback:A,selectedFeedback:m,onSelect:b}),r.jsxs("aside",{className:`review-lens-panel review-lens-panel--${t}`,"data-review-lens-ui":!0,children:[r.jsxs("header",{className:"review-lens-panel__header",children:[r.jsxs("div",{children:[r.jsx("p",{className:"review-lens-kicker",children:"Review Lens"}),r.jsx("h2",{children:u?"Element locked":"Inspecting"})]}),r.jsx("button",{type:"button",onClick:()=>n==null?void 0:n(!1),children:"Close"})]}),$?r.jsx(se,{target:$}):r.jsx("p",{children:"Move over the app to inspect."}),u?r.jsxs("form",{className:"review-lens-feedback-form",onSubmit:h=>{h.preventDefault(),ie()},children:[r.jsx("label",{htmlFor:"review-lens-comment",children:"Feedback"}),r.jsx("textarea",{id:"review-lens-comment",value:f,disabled:!E,onChange:h=>v(h.target.value),placeholder:E?"Describe the UX issue...":"You do not have permission to comment."}),r.jsxs("div",{className:"review-lens-actions",children:[r.jsx("button",{type:"button",onClick:()=>g(void 0),children:"Unlock"}),r.jsx("button",{type:"submit",disabled:!f.trim()||!E,children:"Save feedback"})]})]}):null,r.jsxs("section",{className:"review-lens-comments",children:[r.jsx("h3",{children:"Page feedback"}),A.length===0?r.jsx("p",{children:"No feedback for this view."}):null,A.map(h=>r.jsxs("article",{className:(m==null?void 0:m.id)===h.id?"review-lens-comment review-lens-comment--selected":"review-lens-comment",children:[r.jsx("p",{children:h.comment}),r.jsx("span",{children:h.authorEmail}),h.status==="open"&&P?r.jsx("button",{type:"button",onClick:()=>void i(h.id),children:"Resolve"}):null]},h.id))]})]})]})}function re({target:e,locked:n}){return r.jsx("div",{className:n?"review-lens-highlight review-lens-highlight--locked":"review-lens-highlight",style:{top:e.rect.top+window.scrollY,left:e.rect.left+window.scrollX,width:e.rect.width,height:e.rect.height}})}function oe({feedback:e,selectedFeedback:n,onSelect:t}){return r.jsx(r.Fragment,{children:e.map(o=>{const s=ae(o.selector),a=s==null?void 0:s.getBoundingClientRect();return a?r.jsx("button",{type:"button",className:(n==null?void 0:n.id)===o.id?"review-lens-marker review-lens-marker--selected":"review-lens-marker",style:{top:a.top+window.scrollY,left:a.left+window.scrollX+a.width},onClick:()=>t(o),"aria-label":`Open feedback from ${o.authorEmail}`},o.id):null})})}function se({target:e}){const n=[["Selector",e.selector],["Size",`${e.cssSnapshot.width} x ${e.cssSnapshot.height}`],["Margin",e.cssSnapshot.margin],["Padding",e.cssSnapshot.padding],["Border",e.cssSnapshot.border],["Font",`${e.cssSnapshot.fontSize} / ${e.cssSnapshot.lineHeight}`],["Family",e.cssSnapshot.fontFamily],["Color",e.cssSnapshot.color],["Background",e.cssSnapshot.backgroundColor]];return r.jsx("dl",{className:"review-lens-metrics",children:n.map(([t,o])=>r.jsxs("div",{children:[r.jsx("dt",{children:t}),r.jsx("dd",{children:o})]},t))})}function ae(e){try{return document.querySelector(e)}catch{return null}}w.ReviewLensOverlay=ne,w.ReviewLensProvider=Q,w.buildElementTarget=L,w.createGoogleSheetsAdapter=T,w.normalizeReviewUrl=B,w.useReviewLens=M,Object.defineProperty(w,Symbol.toStringTag,{value:"Module"})}));
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { ReviewLensAdapter } from "../types";
|
|
2
|
+
type GoogleSheetsAdapterConfig = {
|
|
3
|
+
googleClientId: string;
|
|
4
|
+
spreadsheetId: string;
|
|
5
|
+
feedbackSheetName?: string;
|
|
6
|
+
usersSheetName?: string;
|
|
7
|
+
projectsSheetName?: string;
|
|
8
|
+
};
|
|
9
|
+
type TokenClient = {
|
|
10
|
+
requestAccessToken(options?: {
|
|
11
|
+
prompt?: string;
|
|
12
|
+
}): void;
|
|
13
|
+
};
|
|
14
|
+
declare global {
|
|
15
|
+
interface Window {
|
|
16
|
+
google?: {
|
|
17
|
+
accounts: {
|
|
18
|
+
oauth2: {
|
|
19
|
+
initTokenClient(config: {
|
|
20
|
+
client_id: string;
|
|
21
|
+
scope: string;
|
|
22
|
+
callback: (response: {
|
|
23
|
+
access_token?: string;
|
|
24
|
+
error?: string;
|
|
25
|
+
}) => void;
|
|
26
|
+
}): TokenClient;
|
|
27
|
+
};
|
|
28
|
+
};
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
export declare function createGoogleSheetsAdapter(config: GoogleSheetsAdapterConfig): ReviewLensAdapter;
|
|
33
|
+
export {};
|
package/dist/styles.css
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
.review-lens-root{color:#171717;font-family:ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,Segoe UI,sans-serif;position:fixed;z-index:2147483647}.review-lens-highlight{border:2px solid #2563eb;box-shadow:0 0 0 9999px #0f172a14,0 0 0 4px #2563eb2e;pointer-events:none;position:absolute}.review-lens-highlight--locked{border-color:#f97316;box-shadow:0 0 0 9999px #0f172a1f,0 0 0 4px #f9731638}.review-lens-panel{background:#fafafa;border:1px solid #d4d4d4;border-radius:8px;box-shadow:0 24px 70px #0f172a38;box-sizing:border-box;max-height:calc(100vh - 32px);overflow:auto;padding:16px;position:fixed;width:min(380px,calc(100vw - 32px))}.review-lens-panel--top-left{left:16px;top:16px}.review-lens-panel--top-right{right:16px;top:16px}.review-lens-panel--bottom-left{bottom:16px;left:16px}.review-lens-panel--bottom-right{bottom:16px;right:16px}.review-lens-panel__header{align-items:flex-start;display:flex;gap:16px;justify-content:space-between}.review-lens-panel h2,.review-lens-panel h3,.review-lens-panel p{margin:0}.review-lens-panel h2{font-size:18px;line-height:1.25}.review-lens-panel h3{font-size:14px;margin-top:18px}.review-lens-kicker{color:#525252;font-size:11px;font-weight:700;letter-spacing:0;text-transform:uppercase}.review-lens-panel button{background:#171717;border:1px solid #171717;border-radius:6px;color:#fff;cursor:pointer;font:inherit;font-size:13px;min-height:32px;padding:6px 10px}.review-lens-panel button:disabled{cursor:not-allowed;opacity:.45}.review-lens-metrics{border:1px solid #e5e5e5;border-radius:8px;display:grid;gap:0;margin:16px 0 0;overflow:hidden}.review-lens-metrics div{display:grid;grid-template-columns:96px minmax(0,1fr)}.review-lens-metrics dt,.review-lens-metrics dd{border-bottom:1px solid #e5e5e5;font-size:12px;margin:0;min-width:0;padding:8px}.review-lens-metrics dt{background:#f5f5f5;color:#525252;font-weight:700}.review-lens-metrics dd{overflow-wrap:anywhere}.review-lens-feedback-form{display:grid;gap:8px;margin-top:16px}.review-lens-feedback-form label{font-size:13px;font-weight:700}.review-lens-feedback-form textarea{border:1px solid #d4d4d4;border-radius:8px;box-sizing:border-box;font:inherit;min-height:96px;padding:10px;resize:vertical;width:100%}.review-lens-actions{display:flex;gap:8px;justify-content:flex-end}.review-lens-comments{display:grid;gap:8px}.review-lens-comment{border:1px solid #e5e5e5;border-radius:8px;display:grid;gap:6px;padding:10px}.review-lens-comment--selected{border-color:#2563eb}.review-lens-comment span{color:#525252;font-size:12px}.review-lens-marker{background:#f97316;border:2px solid #ffffff;border-radius:999px;box-shadow:0 8px 20px #0f172a3d;cursor:pointer;height:18px;position:absolute;transform:translate(-50%,-50%);width:18px}.review-lens-marker--selected{background:#2563eb}
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
export type FeedbackStatus = "open" | "resolved";
|
|
3
|
+
export type ReviewLensRole = "designer" | "developer" | "admin";
|
|
4
|
+
export type ReviewLensPermission = "create" | "read" | "resolve";
|
|
5
|
+
export type CssSnapshot = {
|
|
6
|
+
margin: string;
|
|
7
|
+
padding: string;
|
|
8
|
+
border: string;
|
|
9
|
+
fontFamily: string;
|
|
10
|
+
fontSize: string;
|
|
11
|
+
lineHeight: string;
|
|
12
|
+
color: string;
|
|
13
|
+
backgroundColor: string;
|
|
14
|
+
width: number;
|
|
15
|
+
height: number;
|
|
16
|
+
};
|
|
17
|
+
export type ElementFingerprint = {
|
|
18
|
+
tagName: string;
|
|
19
|
+
id?: string;
|
|
20
|
+
className?: string;
|
|
21
|
+
textSnippet?: string;
|
|
22
|
+
ariaLabel?: string;
|
|
23
|
+
width: number;
|
|
24
|
+
height: number;
|
|
25
|
+
};
|
|
26
|
+
export type ReviewLensTarget = {
|
|
27
|
+
selector: string;
|
|
28
|
+
selectorStrategy: "stable-attribute" | "css-path";
|
|
29
|
+
fingerprint: ElementFingerprint;
|
|
30
|
+
cssSnapshot: CssSnapshot;
|
|
31
|
+
rect: DOMRect;
|
|
32
|
+
};
|
|
33
|
+
export type ReviewLensFeedback = {
|
|
34
|
+
id: string;
|
|
35
|
+
projectKey: string;
|
|
36
|
+
contentId: string;
|
|
37
|
+
normalizedPath: string;
|
|
38
|
+
originalUrl: string;
|
|
39
|
+
selector: string;
|
|
40
|
+
selectorStrategy: ReviewLensTarget["selectorStrategy"];
|
|
41
|
+
elementFingerprint: ElementFingerprint;
|
|
42
|
+
cssSnapshot: CssSnapshot;
|
|
43
|
+
comment: string;
|
|
44
|
+
status: FeedbackStatus;
|
|
45
|
+
authorEmail: string;
|
|
46
|
+
createdAt: string;
|
|
47
|
+
updatedAt: string;
|
|
48
|
+
resolvedAt?: string;
|
|
49
|
+
resolvedBy?: string;
|
|
50
|
+
};
|
|
51
|
+
export type CreateFeedbackInput = Omit<ReviewLensFeedback, "id" | "status" | "createdAt" | "updatedAt" | "resolvedAt" | "resolvedBy">;
|
|
52
|
+
export type ReviewLensAdapter = {
|
|
53
|
+
getCurrentUser(): Promise<{
|
|
54
|
+
email: string;
|
|
55
|
+
}>;
|
|
56
|
+
getPermissions(projectKey: string): Promise<ReviewLensPermission[]>;
|
|
57
|
+
listFeedback(params: {
|
|
58
|
+
projectKey: string;
|
|
59
|
+
contentId: string;
|
|
60
|
+
normalizedPath: string;
|
|
61
|
+
}): Promise<ReviewLensFeedback[]>;
|
|
62
|
+
createFeedback(input: CreateFeedbackInput): Promise<ReviewLensFeedback>;
|
|
63
|
+
resolveFeedback(id: string, resolvedBy: string): Promise<ReviewLensFeedback>;
|
|
64
|
+
};
|
|
65
|
+
export type ReviewLensConfig = {
|
|
66
|
+
googleClientId?: string;
|
|
67
|
+
spreadsheetId?: string;
|
|
68
|
+
sheetName?: string;
|
|
69
|
+
projectKey: string;
|
|
70
|
+
contentId: string;
|
|
71
|
+
currentUrl?: string;
|
|
72
|
+
normalizeUrl?: (url: string) => string;
|
|
73
|
+
adapter?: ReviewLensAdapter;
|
|
74
|
+
};
|
|
75
|
+
export type ReviewLensProviderProps = {
|
|
76
|
+
config: ReviewLensConfig;
|
|
77
|
+
children: ReactNode;
|
|
78
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function normalizeReviewUrl(url: string): string;
|
package/package.json
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "review-lens-react",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "React overlay for UX review feedback backed by Google Sheets.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./dist/review-lens-react.umd.cjs",
|
|
7
|
+
"module": "./dist/review-lens-react.js",
|
|
8
|
+
"types": "./dist/index.d.ts",
|
|
9
|
+
"exports": {
|
|
10
|
+
".": {
|
|
11
|
+
"types": "./dist/index.d.ts",
|
|
12
|
+
"import": "./dist/review-lens-react.js",
|
|
13
|
+
"require": "./dist/review-lens-react.umd.cjs"
|
|
14
|
+
},
|
|
15
|
+
"./styles.css": "./dist/styles.css"
|
|
16
|
+
},
|
|
17
|
+
"files": [
|
|
18
|
+
"dist"
|
|
19
|
+
],
|
|
20
|
+
"scripts": {
|
|
21
|
+
"build": "vite build && tsc -p tsconfig.build.json",
|
|
22
|
+
"test": "vitest run",
|
|
23
|
+
"typecheck": "tsc --noEmit"
|
|
24
|
+
},
|
|
25
|
+
"peerDependencies": {
|
|
26
|
+
"react": ">=18",
|
|
27
|
+
"react-dom": ">=18"
|
|
28
|
+
},
|
|
29
|
+
"devDependencies": {
|
|
30
|
+
"@vitejs/plugin-react": "^5.0.0",
|
|
31
|
+
"vite": "^6.3.5"
|
|
32
|
+
}
|
|
33
|
+
}
|