@verbumia/feedback 0.2.6 → 0.2.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/chunk-EKU6NNWI.js +40 -0
- package/dist/chunk-EKU6NNWI.js.map +1 -0
- package/dist/{chunk-5RTFWOGT.js → chunk-EMGN6MR4.js} +87 -3
- package/dist/chunk-EMGN6MR4.js.map +1 -0
- package/dist/{client-qgDSbz3A.d.cts → client-CnEK_2SD.d.cts} +114 -1
- package/dist/{client-qgDSbz3A.d.ts → client-CnEK_2SD.d.ts} +114 -1
- package/dist/core/index.cjs +86 -2
- package/dist/core/index.cjs.map +1 -1
- package/dist/core/index.d.cts +2 -2
- package/dist/core/index.d.ts +2 -2
- package/dist/core/index.js +1 -1
- package/dist/{keys-BhuK_fy1.d.cts → keys-2_T5bDpX.d.cts} +1 -1
- package/dist/{keys-CEWu0Htb.d.ts → keys-eHc_lx5v.d.ts} +1 -1
- package/dist/native/index.cjs +317 -48
- package/dist/native/index.cjs.map +1 -1
- package/dist/native/index.d.cts +48 -4
- package/dist/native/index.d.ts +48 -4
- package/dist/native/index.js +200 -48
- package/dist/native/index.js.map +1 -1
- package/dist/react/index.cjs +319 -41
- package/dist/react/index.cjs.map +1 -1
- package/dist/react/index.d.cts +119 -4
- package/dist/react/index.d.ts +119 -4
- package/dist/react/index.js +202 -41
- package/dist/react/index.js.map +1 -1
- package/dist/svelte/index.cjs +86 -2
- package/dist/svelte/index.cjs.map +1 -1
- package/dist/svelte/index.d.cts +2 -2
- package/dist/svelte/index.d.ts +2 -2
- package/dist/svelte/index.js +1 -1
- package/dist/vue/index.cjs +86 -2
- package/dist/vue/index.cjs.map +1 -1
- package/dist/vue/index.d.cts +2 -2
- package/dist/vue/index.d.ts +2 -2
- package/dist/vue/index.js +1 -1
- package/package.json +1 -1
- package/dist/chunk-5RTFWOGT.js.map +0 -1
package/dist/react/index.js
CHANGED
|
@@ -1,10 +1,13 @@
|
|
|
1
|
+
import {
|
|
2
|
+
t
|
|
3
|
+
} from "../chunk-EKU6NNWI.js";
|
|
1
4
|
import "../chunk-5NA2TFPG.js";
|
|
2
5
|
import {
|
|
3
6
|
FeedbackClient,
|
|
4
7
|
FeedbackError,
|
|
5
8
|
hasKeyRegistry,
|
|
6
9
|
resolveKeys
|
|
7
|
-
} from "../chunk-
|
|
10
|
+
} from "../chunk-EMGN6MR4.js";
|
|
8
11
|
|
|
9
12
|
// src/react/plugin.tsx
|
|
10
13
|
import { createPortal } from "react-dom";
|
|
@@ -12,7 +15,7 @@ import { useSyncExternalStore } from "react";
|
|
|
12
15
|
|
|
13
16
|
// src/react/panel.tsx
|
|
14
17
|
import { useCallback, useEffect, useState } from "react";
|
|
15
|
-
import { jsx, jsxs } from "react/jsx-runtime";
|
|
18
|
+
import { Fragment, jsx, jsxs } from "react/jsx-runtime";
|
|
16
19
|
var C = {
|
|
17
20
|
bg: "#0b0f0e",
|
|
18
21
|
panel: "#111714",
|
|
@@ -23,11 +26,14 @@ var C = {
|
|
|
23
26
|
emeraldSoft: "#34d399"
|
|
24
27
|
};
|
|
25
28
|
function FeedbackPanel(props) {
|
|
26
|
-
const { client, keys, namespace, onClose } = props;
|
|
27
|
-
const [consented, setConsented] = useState(
|
|
29
|
+
const { client, keys, namespace, tos = "modal", onClose } = props;
|
|
30
|
+
const [consented, setConsented] = useState(
|
|
31
|
+
tos === "skip" ? true : client.hasConsented
|
|
32
|
+
);
|
|
28
33
|
const [busy, setBusy] = useState(false);
|
|
29
34
|
const [error, setError] = useState(null);
|
|
30
35
|
const [strings, setStrings] = useState([]);
|
|
36
|
+
const [showSource, setShowSource] = useState(false);
|
|
31
37
|
const loadStrings = useCallback(async () => {
|
|
32
38
|
setBusy(true);
|
|
33
39
|
setError(null);
|
|
@@ -134,7 +140,52 @@ function FeedbackPanel(props) {
|
|
|
134
140
|
busy,
|
|
135
141
|
onAccept: accept
|
|
136
142
|
}
|
|
137
|
-
) : busy && !strings.length ? /* @__PURE__ */ jsx("p", { style: { color: C.dim }, children: "Loading\u2026" }) : !strings.length ? /* @__PURE__ */ jsx("p", { style: { color: C.dim }, children: "No strings to review on this view." }) :
|
|
143
|
+
) : busy && !strings.length ? /* @__PURE__ */ jsx("p", { style: { color: C.dim }, children: "Loading\u2026" }) : !strings.length ? /* @__PURE__ */ jsx("p", { style: { color: C.dim }, children: "No strings to review on this view." }) : /* @__PURE__ */ jsxs(Fragment, { children: [
|
|
144
|
+
/* @__PURE__ */ jsx(
|
|
145
|
+
"div",
|
|
146
|
+
{
|
|
147
|
+
style: {
|
|
148
|
+
display: "flex",
|
|
149
|
+
alignItems: "center",
|
|
150
|
+
justifyContent: "flex-end",
|
|
151
|
+
marginBottom: 10
|
|
152
|
+
},
|
|
153
|
+
children: /* @__PURE__ */ jsxs(
|
|
154
|
+
"label",
|
|
155
|
+
{
|
|
156
|
+
style: {
|
|
157
|
+
display: "inline-flex",
|
|
158
|
+
alignItems: "center",
|
|
159
|
+
gap: 8,
|
|
160
|
+
color: C.dim,
|
|
161
|
+
fontSize: 12,
|
|
162
|
+
cursor: "pointer"
|
|
163
|
+
},
|
|
164
|
+
children: [
|
|
165
|
+
/* @__PURE__ */ jsx(
|
|
166
|
+
"input",
|
|
167
|
+
{
|
|
168
|
+
type: "checkbox",
|
|
169
|
+
checked: showSource,
|
|
170
|
+
onChange: (e) => setShowSource(e.target.checked)
|
|
171
|
+
}
|
|
172
|
+
),
|
|
173
|
+
t(client.language, "showOriginal")
|
|
174
|
+
]
|
|
175
|
+
}
|
|
176
|
+
)
|
|
177
|
+
}
|
|
178
|
+
),
|
|
179
|
+
strings.map((s) => /* @__PURE__ */ jsx(
|
|
180
|
+
StringRow,
|
|
181
|
+
{
|
|
182
|
+
s,
|
|
183
|
+
client,
|
|
184
|
+
showSource
|
|
185
|
+
},
|
|
186
|
+
`${s.namespace}:${s.key}`
|
|
187
|
+
))
|
|
188
|
+
] })
|
|
138
189
|
] }),
|
|
139
190
|
/* @__PURE__ */ jsx(
|
|
140
191
|
"footer",
|
|
@@ -181,11 +232,13 @@ function ConsentStep(props) {
|
|
|
181
232
|
] });
|
|
182
233
|
}
|
|
183
234
|
function StringRow(props) {
|
|
184
|
-
const { s, client } = props;
|
|
235
|
+
const { s, client, showSource } = props;
|
|
185
236
|
const [mine, setMine] = useState(s.my_rating);
|
|
186
237
|
const [showSuggest, setShowSuggest] = useState(false);
|
|
187
|
-
const
|
|
238
|
+
const mySuggestionId = s.my_suggestion ? s.my_suggestion.id : null;
|
|
239
|
+
const [text, setText] = useState(s.my_suggestion?.text ?? "");
|
|
188
240
|
const [sent, setSent] = useState(false);
|
|
241
|
+
const [editError, setEditError] = useState(null);
|
|
189
242
|
const rate = (stars) => {
|
|
190
243
|
setMine(stars);
|
|
191
244
|
client.rate({
|
|
@@ -196,19 +249,35 @@ function StringRow(props) {
|
|
|
196
249
|
stars
|
|
197
250
|
});
|
|
198
251
|
};
|
|
199
|
-
const submitSuggestion = () => {
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
252
|
+
const submitSuggestion = async () => {
|
|
253
|
+
const trimmed = text.trim();
|
|
254
|
+
if (!trimmed) return;
|
|
255
|
+
setEditError(null);
|
|
256
|
+
try {
|
|
257
|
+
if (mySuggestionId) {
|
|
258
|
+
await client.editSuggestion(mySuggestionId, trimmed);
|
|
259
|
+
} else {
|
|
260
|
+
client.suggest({
|
|
261
|
+
namespace: s.namespace,
|
|
262
|
+
key: s.key,
|
|
263
|
+
language: client.language,
|
|
264
|
+
translation_hash: s.translation_hash,
|
|
265
|
+
suggested_text: trimmed
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
setSent(true);
|
|
269
|
+
setShowSuggest(false);
|
|
270
|
+
} catch (e) {
|
|
271
|
+
setEditError(
|
|
272
|
+
e instanceof Error ? e.message : "Could not update the suggestion"
|
|
273
|
+
);
|
|
274
|
+
}
|
|
211
275
|
};
|
|
276
|
+
const showSourceRow = showSource && s.source_locale !== client.language;
|
|
277
|
+
const submitLabel = t(
|
|
278
|
+
client.language,
|
|
279
|
+
mySuggestionId ? "updateMySuggestion" : "submitSuggestion"
|
|
280
|
+
);
|
|
212
281
|
return /* @__PURE__ */ jsxs(
|
|
213
282
|
"div",
|
|
214
283
|
{
|
|
@@ -225,6 +294,19 @@ function StringRow(props) {
|
|
|
225
294
|
" \xB7 ",
|
|
226
295
|
s.key
|
|
227
296
|
] }),
|
|
297
|
+
showSourceRow ? /* @__PURE__ */ jsx(
|
|
298
|
+
"div",
|
|
299
|
+
{
|
|
300
|
+
"data-testid": "source-row",
|
|
301
|
+
style: {
|
|
302
|
+
margin: "4px 0 2px",
|
|
303
|
+
fontSize: 12,
|
|
304
|
+
color: C.dim,
|
|
305
|
+
fontStyle: "italic"
|
|
306
|
+
},
|
|
307
|
+
children: s.source_text
|
|
308
|
+
}
|
|
309
|
+
) : null,
|
|
228
310
|
/* @__PURE__ */ jsx("div", { style: { margin: "6px 0 10px", fontSize: 15 }, children: s.value }),
|
|
229
311
|
/* @__PURE__ */ jsxs("div", { style: { display: "flex", gap: 4 }, children: [
|
|
230
312
|
[1, 2, 3, 4, 5].map((n) => /* @__PURE__ */ jsx(
|
|
@@ -259,7 +341,7 @@ function StringRow(props) {
|
|
|
259
341
|
fontSize: 12,
|
|
260
342
|
cursor: "pointer"
|
|
261
343
|
},
|
|
262
|
-
children: sent ? "Suggested \u2713" :
|
|
344
|
+
children: sent ? "Suggested \u2713" : submitLabel
|
|
263
345
|
}
|
|
264
346
|
)
|
|
265
347
|
] }),
|
|
@@ -283,11 +365,12 @@ function StringRow(props) {
|
|
|
283
365
|
}
|
|
284
366
|
}
|
|
285
367
|
),
|
|
368
|
+
editError ? /* @__PURE__ */ jsx("p", { style: { color: "#f87171", fontSize: 12, marginTop: 6 }, children: editError }) : null,
|
|
286
369
|
/* @__PURE__ */ jsx(
|
|
287
370
|
"button",
|
|
288
371
|
{
|
|
289
372
|
type: "button",
|
|
290
|
-
onClick: submitSuggestion,
|
|
373
|
+
onClick: () => void submitSuggestion(),
|
|
291
374
|
style: {
|
|
292
375
|
marginTop: 6,
|
|
293
376
|
background: C.emerald,
|
|
@@ -298,7 +381,7 @@ function StringRow(props) {
|
|
|
298
381
|
fontWeight: 700,
|
|
299
382
|
cursor: "pointer"
|
|
300
383
|
},
|
|
301
|
-
children:
|
|
384
|
+
children: submitLabel
|
|
302
385
|
}
|
|
303
386
|
)
|
|
304
387
|
] })
|
|
@@ -310,42 +393,73 @@ function StringRow(props) {
|
|
|
310
393
|
// src/react/plugin.tsx
|
|
311
394
|
import { jsx as jsx2 } from "react/jsx-runtime";
|
|
312
395
|
function makeStore() {
|
|
313
|
-
let
|
|
396
|
+
let state = { isOpen: false, snapshotKeys: void 0 };
|
|
314
397
|
const listeners = /* @__PURE__ */ new Set();
|
|
315
398
|
return {
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
open
|
|
320
|
-
|
|
399
|
+
getState: () => state,
|
|
400
|
+
setOpen(open, snapshotKeys) {
|
|
401
|
+
const next = {
|
|
402
|
+
isOpen: open,
|
|
403
|
+
snapshotKeys: open ? snapshotKeys : void 0
|
|
404
|
+
};
|
|
405
|
+
if (state.isOpen === next.isOpen && state.snapshotKeys === next.snapshotKeys) {
|
|
406
|
+
return;
|
|
321
407
|
}
|
|
408
|
+
state = next;
|
|
409
|
+
listeners.forEach((l) => l());
|
|
322
410
|
},
|
|
323
411
|
subscribe(l) {
|
|
324
412
|
listeners.add(l);
|
|
325
|
-
return () =>
|
|
413
|
+
return () => {
|
|
414
|
+
listeners.delete(l);
|
|
415
|
+
};
|
|
326
416
|
}
|
|
327
417
|
};
|
|
328
418
|
}
|
|
419
|
+
async function captureCurrentViewSnapshot(i18next) {
|
|
420
|
+
const reg = globalThis.__verbumia_key_registry__;
|
|
421
|
+
if (!reg) return [];
|
|
422
|
+
const lng = i18next?.language;
|
|
423
|
+
const change = i18next?.changeLanguage;
|
|
424
|
+
if (typeof change === "function" && typeof lng === "string" && lng) {
|
|
425
|
+
reg.reset?.();
|
|
426
|
+
try {
|
|
427
|
+
await change(lng);
|
|
428
|
+
} catch {
|
|
429
|
+
}
|
|
430
|
+
await new Promise((r) => {
|
|
431
|
+
if (typeof requestAnimationFrame === "function") {
|
|
432
|
+
requestAnimationFrame(() => r());
|
|
433
|
+
} else {
|
|
434
|
+
setTimeout(() => r(), 16);
|
|
435
|
+
}
|
|
436
|
+
});
|
|
437
|
+
return reg.snapshot();
|
|
438
|
+
}
|
|
439
|
+
return reg.snapshot();
|
|
440
|
+
}
|
|
329
441
|
function feedbackPlugin(options) {
|
|
330
442
|
const store = makeStore();
|
|
331
443
|
let client = null;
|
|
332
444
|
function Outlet() {
|
|
333
|
-
const
|
|
445
|
+
const state = useSyncExternalStore(
|
|
334
446
|
store.subscribe,
|
|
335
|
-
store.
|
|
336
|
-
store.
|
|
447
|
+
store.getState,
|
|
448
|
+
store.getState
|
|
337
449
|
);
|
|
338
|
-
if (!isOpen || !client || typeof document === "undefined") return null;
|
|
450
|
+
if (!state.isOpen || !client || typeof document === "undefined") return null;
|
|
339
451
|
const c = client;
|
|
452
|
+
const panelKeys = options.keys ?? state.snapshotKeys;
|
|
340
453
|
return createPortal(
|
|
341
454
|
/* @__PURE__ */ jsx2(
|
|
342
455
|
FeedbackPanel,
|
|
343
456
|
{
|
|
344
457
|
client: c,
|
|
345
|
-
keys:
|
|
458
|
+
keys: panelKeys,
|
|
346
459
|
namespace: options.namespace,
|
|
460
|
+
tos: options.tos ?? "modal",
|
|
347
461
|
onClose: () => {
|
|
348
|
-
store.
|
|
462
|
+
store.setOpen(false);
|
|
349
463
|
void c.flush();
|
|
350
464
|
}
|
|
351
465
|
}
|
|
@@ -357,24 +471,71 @@ function feedbackPlugin(options) {
|
|
|
357
471
|
name: "@verbumia/feedback",
|
|
358
472
|
setup(ctx) {
|
|
359
473
|
const initialLanguage = options.language ?? ctx.i18n?.language ?? ctx.config.defaultLocale;
|
|
474
|
+
const tos = options.tos ?? "modal";
|
|
360
475
|
client = new FeedbackClient({
|
|
361
476
|
apiBase: options.apiBase ?? ctx.config.apiBase ?? "https://api.verbumia.dev",
|
|
362
477
|
projectId: options.projectId ?? ctx.config.projectUuid,
|
|
363
478
|
language: initialLanguage,
|
|
364
479
|
endUserId: options.endUserId,
|
|
365
|
-
fetchImpl: options.fetchImpl
|
|
480
|
+
fetchImpl: options.fetchImpl,
|
|
481
|
+
apiKey: options.apiKey,
|
|
482
|
+
autoAcceptTos: tos !== "skip"
|
|
366
483
|
});
|
|
484
|
+
const clientRef = client;
|
|
485
|
+
let addonState = null;
|
|
486
|
+
const cta = options.cta ?? "auto";
|
|
487
|
+
const scope = options.scope ?? "current-view";
|
|
488
|
+
const ctxI18next = ctx.i18n?.i18next;
|
|
489
|
+
const refreshState = async () => {
|
|
490
|
+
try {
|
|
491
|
+
const next = await clientRef.getAddonState();
|
|
492
|
+
if (next !== null) addonState = next;
|
|
493
|
+
} catch {
|
|
494
|
+
}
|
|
495
|
+
};
|
|
496
|
+
if (options.apiKey) void refreshState();
|
|
367
497
|
let langUnsub;
|
|
368
498
|
if (typeof ctx.onLanguageChange === "function") {
|
|
369
|
-
langUnsub = ctx.onLanguageChange((lng) =>
|
|
499
|
+
langUnsub = ctx.onLanguageChange((lng) => {
|
|
500
|
+
clientRef.setLanguage(lng);
|
|
501
|
+
void refreshState();
|
|
502
|
+
});
|
|
370
503
|
}
|
|
371
504
|
const controller = {
|
|
372
|
-
open: () =>
|
|
505
|
+
open: async () => {
|
|
506
|
+
if (scope === "current-view") {
|
|
507
|
+
const snapshot = await captureCurrentViewSnapshot(ctxI18next);
|
|
508
|
+
store.setOpen(true, snapshot);
|
|
509
|
+
return;
|
|
510
|
+
}
|
|
511
|
+
store.setOpen(true);
|
|
512
|
+
},
|
|
373
513
|
close: () => {
|
|
374
|
-
store.
|
|
375
|
-
void
|
|
514
|
+
store.setOpen(false);
|
|
515
|
+
void clientRef?.flush();
|
|
516
|
+
},
|
|
517
|
+
client: clientRef,
|
|
518
|
+
get tosVersion() {
|
|
519
|
+
return clientRef.tosVersion;
|
|
520
|
+
},
|
|
521
|
+
get hasAcceptedTos() {
|
|
522
|
+
return clientRef.hasAcceptedTos;
|
|
376
523
|
},
|
|
377
|
-
|
|
524
|
+
acceptTos: async () => {
|
|
525
|
+
await clientRef.acceptTos();
|
|
526
|
+
if (!options.apiKey) await refreshState();
|
|
527
|
+
},
|
|
528
|
+
get isActive() {
|
|
529
|
+
if (cta === "show") return true;
|
|
530
|
+
if (cta === "hide") return false;
|
|
531
|
+
return addonState ? addonState.isActive : null;
|
|
532
|
+
},
|
|
533
|
+
get enabledLanguages() {
|
|
534
|
+
return addonState ? addonState.enabledLanguages : null;
|
|
535
|
+
},
|
|
536
|
+
get sku() {
|
|
537
|
+
return addonState ? addonState.sku : null;
|
|
538
|
+
}
|
|
378
539
|
};
|
|
379
540
|
options.onReady?.(controller);
|
|
380
541
|
if (options.controllerRef) options.controllerRef.current = controller;
|
package/dist/react/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/react/plugin.tsx","../../src/react/panel.tsx"],"sourcesContent":["/**\n * `@verbumia/feedback` as a PLUGIN of the `@verbumia/*-i18n` provider.\n *\n * Architecture (task 599): NO second React context. The customer adds\n * `feedbackPlugin(...)` to the i18n provider's `plugins` slot. The\n * provider calls `setup({ i18n, config })` once (we reuse its apiBase /\n * projectUuid / locale — no re-config) and renders our `render()` as an\n * ISOLATED sibling leaf. The panel's open/close lives in a tiny private\n * store the outlet subscribes to via useSyncExternalStore, so toggling\n * feedback NEVER re-renders the host app. The customer triggers it via\n * their own CTA through the imperative controller.\n */\nimport { createPortal } from \"react-dom\";\nimport { useSyncExternalStore, type ReactNode } from \"react\";\n\nimport { FeedbackClient } from \"../core/client\";\nimport type { DeclaredKey } from \"../core/types\";\nimport { FeedbackPanel } from \"./panel\";\n\n/** Structural mirror of `@verbumia/react-i18next`'s VerbumiaPlugin —\n * kept local so feedback has no build-time dep on the i18n SDK. The\n * `i18n` and `onLanguageChange` fields are optional so a plugin attached\n * to a non-Verbumia host (or to a pre-1.0.5 react-i18next) keeps\n * working — runtime language re-sync is just disabled. From feedback\n * ≥0.2.6, the peerDep on `@verbumia/react-i18next` is `>=1.0.5`, so the\n * lang-change subscription path is guaranteed available in matched\n * installs. */\nexport interface I18nPluginContext {\n i18n?: { language?: string };\n config: {\n apiBase?: string;\n projectUuid: string;\n defaultLocale: string;\n };\n /** #806 — subscribe to runtime language changes (see VerbumiaPluginContext\n * in `@verbumia/react-i18next` ≥1.0.5). Returns an unsubscribe fn the\n * plugin MUST call from teardown. Optional so attaching to a\n * pre-1.0.5 react-i18next falls back gracefully (no auto re-sync;\n * consumer can still pass `options.language` explicitly). */\n onLanguageChange?: (cb: (lng: string) => void) => () => void;\n}\nexport interface I18nPlugin {\n name: string;\n setup?: (ctx: I18nPluginContext) => void | (() => void);\n render?: () => ReactNode;\n}\n\nexport interface FeedbackController {\n open: () => void;\n close: () => void;\n /** The underlying client (advanced; usually unused). */\n client: FeedbackClient;\n}\n\nexport interface FeedbackPluginOptions {\n // NOTE: no `tosVersion` — it is an SDK build-time constant\n // (SDK_TOS_VERSION, task 616), sent automatically; never integrator-set.\n /** Override language; defaults to the i18n provider's defaultLocale. */\n language?: string;\n /** Override apiBase; defaults to the i18n provider's apiBase. */\n apiBase?: string;\n /** Override projectId; defaults to the i18n provider's projectUuid. */\n projectId?: string;\n /** Optional opaque end-user id (server generates one when absent). */\n endUserId?: string;\n /** Explicit on-screen keys (else the i18n key registry, if present). */\n keys?: DeclaredKey[];\n /** Optional namespace filter (CONTRACT v6, additive). When set (a\n * single namespace or a list) the panel shows only resolved keys in\n * that namespace — applied AFTER rendered-scoping (`rendered ∩\n * namespace`). Unset ⇒ all resolved keys (v5 behaviour). */\n namespace?: string | string[];\n /** Receives the imperative { open, close } handle for the host CTA. */\n onReady?: (controller: FeedbackController) => void;\n /** Alt delivery: a ref object that receives the controller. */\n controllerRef?: { current: FeedbackController | null };\n /** Injected fetch (tests / RN polyfills). */\n fetchImpl?: typeof fetch;\n}\n\nfunction makeStore() {\n let open = false;\n const listeners = new Set<() => void>();\n return {\n isOpen: () => open,\n set(v: boolean) {\n if (open !== v) {\n open = v;\n listeners.forEach((l) => l());\n }\n },\n subscribe(l: () => void) {\n listeners.add(l);\n return () => listeners.delete(l);\n },\n };\n}\n\nexport function feedbackPlugin(options: FeedbackPluginOptions): I18nPlugin {\n const store = makeStore();\n let client: FeedbackClient | null = null;\n\n function Outlet() {\n const isOpen = useSyncExternalStore(\n store.subscribe,\n store.isOpen,\n store.isOpen,\n );\n if (!isOpen || !client || typeof document === \"undefined\") return null;\n const c = client;\n return createPortal(\n <FeedbackPanel\n client={c}\n keys={options.keys}\n namespace={options.namespace}\n onClose={() => {\n store.set(false);\n void c.flush();\n }}\n />,\n document.body,\n );\n }\n\n return {\n name: \"@verbumia/feedback\",\n setup(ctx) {\n // #806 SeedSower lang-change init source: prefer an explicit\n // plugin option, then the i18n provider's CURRENT language (so\n // mounts that happen after a boot-time language flip get it\n // right), then the boot-snapshot defaultLocale as the floor.\n const initialLanguage =\n options.language ?? ctx.i18n?.language ?? ctx.config.defaultLocale;\n client = new FeedbackClient({\n apiBase:\n options.apiBase ?? ctx.config.apiBase ?? \"https://api.verbumia.dev\",\n projectId: options.projectId ?? ctx.config.projectUuid,\n language: initialLanguage,\n endUserId: options.endUserId,\n fetchImpl: options.fetchImpl,\n });\n // #806 — re-sync the client whenever the host changes the\n // language at runtime. Optional: if the host's i18n provider\n // predates the plugin-context `onLanguageChange` field (or the\n // plugin is mounted on a non-Verbumia host), this just no-ops\n // and the consumer can still pass `options.language` explicitly.\n let langUnsub: (() => void) | undefined;\n if (typeof ctx.onLanguageChange === \"function\") {\n langUnsub = ctx.onLanguageChange((lng) => client?.setLanguage(lng));\n }\n const controller: FeedbackController = {\n open: () => store.set(true),\n close: () => {\n store.set(false);\n void client?.flush();\n },\n client,\n };\n options.onReady?.(controller);\n if (options.controllerRef) options.controllerRef.current = controller;\n return () => {\n langUnsub?.();\n if (options.controllerRef) options.controllerRef.current = null;\n void client?.flush();\n };\n },\n render: () => <Outlet />,\n };\n}\n","import { useCallback, useEffect, useState } from \"react\";\n\nimport type { FeedbackClient } from \"../core/client\";\nimport { resolveKeys } from \"../core/keys\";\nimport type { DeclaredKey, FeedbackString } from \"../core/types\";\n\nconst C = {\n bg: \"#0b0f0e\",\n panel: \"#111714\",\n border: \"#1f2a25\",\n text: \"#e7f5ef\",\n dim: \"#8aa79b\",\n emerald: \"#10b981\",\n emeraldSoft: \"#34d399\",\n};\n\nexport function FeedbackPanel(props: {\n client: FeedbackClient;\n keys?: DeclaredKey[];\n namespace?: string | string[];\n onClose: () => void;\n}) {\n const { client, keys, namespace, onClose } = props;\n const [consented, setConsented] = useState(client.hasConsented);\n const [busy, setBusy] = useState(false);\n const [error, setError] = useState<string | null>(null);\n const [strings, setStrings] = useState<FeedbackString[]>([]);\n\n const loadStrings = useCallback(async () => {\n setBusy(true);\n setError(null);\n try {\n // On-screen scoping is NORMATIVE (spec ltm 373, CONTRACT v5): show\n // ONLY rendered keys. Empty -> \"no strings on this view\"; never\n // fall back to fetching the whole project.\n const resolved = resolveKeys(keys, namespace);\n if (!resolved.length) {\n // #806 SeedSower P1 — when the panel opens with NO explicit\n // `keys` prop (i.e. it depended on the i18n SDK's on-screen\n // registry), an empty resolve almost always means the host's\n // `useTranslation` imports went to `react-i18next` instead of\n // `@verbumia/react-i18next`. Either way, the keys never fed the\n // registry. Log a single, actionable hint so the integrator\n // doesn't waste a debug cycle on \"the widget is broken\".\n if (!keys || keys.length === 0) {\n // eslint-disable-next-line no-console\n console.warn(\n \"[verbumia/feedback] registry empty — are your useTranslation imports coming from @verbumia/react-i18next?\",\n );\n }\n setStrings([]);\n return;\n }\n const res = await client.getStrings({ keys: resolved });\n setStrings(res.strings);\n } catch (e) {\n setError(e instanceof Error ? e.message : \"Failed to load strings\");\n } finally {\n setBusy(false);\n }\n }, [client, keys, namespace]);\n\n useEffect(() => {\n if (consented) void loadStrings();\n }, [consented, loadStrings]);\n\n const accept = useCallback(async () => {\n setBusy(true);\n setError(null);\n try {\n await client.acceptTos();\n setConsented(true);\n } catch (e) {\n setError(e instanceof Error ? e.message : \"Could not accept the terms\");\n } finally {\n setBusy(false);\n }\n }, [client]);\n\n return (\n <div\n role=\"dialog\"\n aria-label=\"Translation feedback\"\n style={{\n position: \"fixed\",\n inset: 0,\n zIndex: 2147483600,\n background: \"rgba(0,0,0,.55)\",\n display: \"flex\",\n justifyContent: \"flex-end\",\n }}\n onClick={onClose}\n >\n <div\n onClick={(e) => e.stopPropagation()}\n style={{\n width: \"min(420px, 100%)\",\n height: \"100%\",\n background: C.bg,\n color: C.text,\n borderLeft: `1px solid ${C.border}`,\n display: \"flex\",\n flexDirection: \"column\",\n fontFamily: \"system-ui, sans-serif\",\n }}\n >\n <header\n style={{\n padding: \"16px 18px\",\n borderBottom: `1px solid ${C.border}`,\n display: \"flex\",\n justifyContent: \"space-between\",\n alignItems: \"center\",\n }}\n >\n <strong style={{ color: C.emeraldSoft }}>Translation feedback</strong>\n <button\n type=\"button\"\n onClick={onClose}\n aria-label=\"Close\"\n style={{\n background: \"transparent\",\n color: C.dim,\n border: \"none\",\n fontSize: 20,\n cursor: \"pointer\",\n }}\n >\n ×\n </button>\n </header>\n\n <div style={{ padding: 18, overflowY: \"auto\", flex: 1 }}>\n {error && (\n <p style={{ color: \"#f87171\", fontSize: 13 }}>{error}</p>\n )}\n\n {!consented ? (\n <ConsentStep\n version={client.tosVersion}\n busy={busy}\n onAccept={accept}\n />\n ) : busy && !strings.length ? (\n <p style={{ color: C.dim }}>Loading…</p>\n ) : !strings.length ? (\n <p style={{ color: C.dim }}>No strings to review on this view.</p>\n ) : (\n strings.map((s) => (\n <StringRow key={`${s.namespace}:${s.key}`} s={s} client={client} />\n ))\n )}\n </div>\n <footer\n style={{\n padding: \"10px 18px\",\n borderTop: `1px solid ${C.border}`,\n color: C.dim,\n fontSize: 11,\n }}\n >\n Powered by Verbumia\n </footer>\n </div>\n </div>\n );\n}\n\nfunction ConsentStep(props: {\n version?: string;\n busy: boolean;\n onAccept: () => void;\n}) {\n return (\n <div>\n <p style={{ lineHeight: 1.5, fontSize: 14 }}>\n Help improve the translations in this app. Your ratings and\n suggestions are sent to the app owner via Verbumia. Don’t submit\n personal or sensitive information.\n </p>\n <button\n type=\"button\"\n disabled={props.busy}\n onClick={props.onAccept}\n style={{\n marginTop: 12,\n width: \"100%\",\n background: C.emerald,\n color: \"#03110c\",\n border: \"none\",\n borderRadius: 8,\n padding: \"12px 14px\",\n fontWeight: 700,\n cursor: props.busy ? \"default\" : \"pointer\",\n opacity: props.busy ? 0.6 : 1,\n }}\n >\n {props.busy\n ? \"…\"\n : `I accept the terms${props.version ? ` (v${props.version})` : \"\"}`}\n </button>\n </div>\n );\n}\n\nfunction StringRow(props: { s: FeedbackString; client: FeedbackClient }) {\n const { s, client } = props;\n const [mine, setMine] = useState<number | null>(s.my_rating);\n const [showSuggest, setShowSuggest] = useState(false);\n const [text, setText] = useState(\"\");\n const [sent, setSent] = useState(false);\n\n const rate = (stars: number) => {\n setMine(stars);\n client.rate({\n namespace: s.namespace,\n key: s.key,\n language: client.language,\n translation_hash: s.translation_hash,\n stars,\n });\n };\n\n const submitSuggestion = () => {\n if (!text.trim()) return;\n client.suggest({\n namespace: s.namespace,\n key: s.key,\n language: client.language,\n translation_hash: s.translation_hash,\n suggested_text: text.trim(),\n });\n setSent(true);\n setShowSuggest(false);\n setText(\"\");\n };\n\n return (\n <div\n style={{\n background: C.panel,\n border: `1px solid ${C.border}`,\n borderRadius: 10,\n padding: 12,\n marginBottom: 10,\n }}\n >\n <div style={{ fontSize: 12, color: C.dim }}>\n {s.namespace} · {s.key}\n </div>\n <div style={{ margin: \"6px 0 10px\", fontSize: 15 }}>{s.value}</div>\n <div style={{ display: \"flex\", gap: 4 }}>\n {[1, 2, 3, 4, 5].map((n) => (\n <button\n key={n}\n type=\"button\"\n aria-label={`${n} star${n > 1 ? \"s\" : \"\"}`}\n onClick={() => rate(n)}\n style={{\n background: \"transparent\",\n border: \"none\",\n cursor: \"pointer\",\n fontSize: 20,\n color: mine && n <= mine ? C.emeraldSoft : C.border,\n }}\n >\n ★\n </button>\n ))}\n <button\n type=\"button\"\n onClick={() => setShowSuggest((v) => !v)}\n style={{\n marginLeft: \"auto\",\n background: \"transparent\",\n color: C.emeraldSoft,\n border: `1px solid ${C.border}`,\n borderRadius: 6,\n padding: \"4px 10px\",\n fontSize: 12,\n cursor: \"pointer\",\n }}\n >\n {sent ? \"Suggested ✓\" : \"Suggest\"}\n </button>\n </div>\n {showSuggest && (\n <div style={{ marginTop: 10 }}>\n <textarea\n value={text}\n onChange={(e) => setText(e.target.value)}\n rows={3}\n placeholder=\"Your suggested translation…\"\n style={{\n width: \"100%\",\n background: C.bg,\n color: C.text,\n border: `1px solid ${C.border}`,\n borderRadius: 6,\n padding: 8,\n fontSize: 14,\n resize: \"vertical\",\n }}\n />\n <button\n type=\"button\"\n onClick={submitSuggestion}\n style={{\n marginTop: 6,\n background: C.emerald,\n color: \"#03110c\",\n border: \"none\",\n borderRadius: 6,\n padding: \"8px 12px\",\n fontWeight: 700,\n cursor: \"pointer\",\n }}\n >\n Send suggestion\n </button>\n </div>\n )}\n </div>\n );\n}\n"],"mappings":"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","names":["jsx"]}
|
|
1
|
+
{"version":3,"sources":["../../src/react/plugin.tsx","../../src/react/panel.tsx"],"sourcesContent":["/**\n * `@verbumia/feedback` as a PLUGIN of the `@verbumia/*-i18n` provider.\n *\n * Architecture (task 599): NO second React context. The customer adds\n * `feedbackPlugin(...)` to the i18n provider's `plugins` slot. The\n * provider calls `setup({ i18n, config })` once (we reuse its apiBase /\n * projectUuid / locale — no re-config) and renders our `render()` as an\n * ISOLATED sibling leaf. The panel's open/close lives in a tiny private\n * store the outlet subscribes to via useSyncExternalStore, so toggling\n * feedback NEVER re-renders the host app. The customer triggers it via\n * their own CTA through the imperative controller.\n */\nimport { createPortal } from \"react-dom\";\nimport { useSyncExternalStore, type ReactNode } from \"react\";\n\nimport { FeedbackClient } from \"../core/client\";\nimport type { DeclaredKey, FeedbackAddonState } from \"../core/types\";\nimport { FeedbackPanel } from \"./panel\";\n\n/** Structural mirror of `@verbumia/react-i18next`'s VerbumiaPlugin —\n * kept local so feedback has no build-time dep on the i18n SDK. The\n * `i18n` and `onLanguageChange` fields are optional so a plugin attached\n * to a non-Verbumia host (or to a pre-1.0.5 react-i18next) keeps\n * working — runtime language re-sync is just disabled. From feedback\n * ≥0.2.6, the peerDep on `@verbumia/react-i18next` is `>=1.0.5`, so the\n * lang-change subscription path is guaranteed available in matched\n * installs. */\nexport interface I18nPluginContext {\n i18n?: {\n language?: string;\n /** 0.2.7 — direct handle on the underlying `i18next` instance the\n * `@verbumia/react-i18next` provider exposes. Used by the\n * `scope: \"current-view\"` snapshot path: a same-locale\n * `changeLanguage(language)` re-emits `languageChanged` so every\n * bound consumer re-renders → `t()` calls repopulate the registry.\n * Optional so the plugin still works on non-Verbumia hosts (it\n * falls back to a no-rerender registry snapshot in that case). */\n i18next?: {\n language?: string;\n changeLanguage?: (lng: string) => Promise<unknown>;\n };\n };\n config: {\n apiBase?: string;\n projectUuid: string;\n defaultLocale: string;\n };\n /** #806 — subscribe to runtime language changes (see VerbumiaPluginContext\n * in `@verbumia/react-i18next` ≥1.0.5). Returns an unsubscribe fn the\n * plugin MUST call from teardown. Optional so attaching to a\n * pre-1.0.5 react-i18next falls back gracefully (no auto re-sync;\n * consumer can still pass `options.language` explicitly). */\n onLanguageChange?: (cb: (lng: string) => void) => () => void;\n}\nexport interface I18nPlugin {\n name: string;\n setup?: (ctx: I18nPluginContext) => void | (() => void);\n render?: () => ReactNode;\n}\n\nexport interface FeedbackController {\n /**\n * Opens the panel. From 0.2.7 the call is **async** (returns\n * `Promise<void>`) so the `scope: \"current-view\"` snapshot sequence\n * can run before the modal mounts (reset registry → force\n * languageChanged → 1-frame yield → snapshot). Hosts that do NOT\n * await still work — the modal mounts when the promise resolves.\n * Under `scope: \"all\"` (or when the i18next instance isn't reachable)\n * `open()` resolves on the next microtask with no extra work.\n */\n open: () => Promise<void>;\n close: () => void;\n /** The underlying client (advanced; usually unused). */\n client: FeedbackClient;\n /** ToS version currently required by the backend (SDK build-time\n * constant — see core/tos.ts). 0.2.7+. */\n readonly tosVersion: string;\n /** Whether this end-user has a live session-token bundle (i.e. has\n * accepted the current ToS version). Mirrors the same persisted state\n * the built-in modal writes to, so an external ToS page that calls\n * `acceptTos()` flips this to `true` immediately. 0.2.7+. */\n readonly hasAcceptedTos: boolean;\n /** Programmatically POST `/v1/feedback/tos` and persist the returned\n * token bundle into the SDK's shared store. Used by hosts that\n * build their own ToS page (`feedbackPlugin({ tos: \"skip\" })`). Thin\n * alias over `FeedbackClient.acceptTos()` — idempotent (a second\n * call returns the in-flight / existing bundle without re-POSTing).\n * 0.2.7+. */\n acceptTos: () => Promise<void>;\n /** 0.2.7 — feedback-addon active flag for this project, sourced from\n * `GET /v1/projects/{p}/feedback-addon/state` + composed with the\n * plugin's `cta` option:\n * - `cta: \"auto\"` (default): mirrors `state.isActive` (so a host\n * that hides its CTA on `!isActive` does the right thing for\n * Starter / no SKU).\n * - `cta: \"show\"`: always `true` (force the host's CTA).\n * - `cta: \"hide\"`: always `false`.\n * `null` before the first state fetch resolves (e.g. on a host that\n * didn't pass `apiKey` and hasn't accepted ToS yet). */\n readonly isActive: boolean | null;\n /** 0.2.7 — the languages the SDK is licensed to rate strings in.\n * Mirrors `state.enabledLanguages` verbatim: `string[]` for Starter\n * / no SKU, literal `\"all\"` for Unlimited. `null` before the first\n * state fetch resolves. */\n readonly enabledLanguages: string[] | \"all\" | null;\n /** 0.2.7 — raw SKU code (`feedback_starter` / `feedback_unlimited`\n * / `null`). Useful for downstream UI / billing dashboards. */\n readonly sku: \"feedback_starter\" | \"feedback_unlimited\" | null;\n}\n\nexport interface FeedbackPluginOptions {\n // NOTE: no `tosVersion` — it is an SDK build-time constant\n // (SDK_TOS_VERSION, task 616), sent automatically; never integrator-set.\n /** Override language; defaults to the i18n provider's defaultLocale. */\n language?: string;\n /** Override apiBase; defaults to the i18n provider's apiBase. */\n apiBase?: string;\n /** Override projectId; defaults to the i18n provider's projectUuid. */\n projectId?: string;\n /** Optional opaque end-user id (server generates one when absent). */\n endUserId?: string;\n /** Explicit on-screen keys (else the i18n key registry, if present). */\n keys?: DeclaredKey[];\n /** Optional namespace filter (CONTRACT v6, additive). When set (a\n * single namespace or a list) the panel shows only resolved keys in\n * that namespace — applied AFTER rendered-scoping (`rendered ∩\n * namespace`). Unset ⇒ all resolved keys (v5 behaviour). */\n namespace?: string | string[];\n /** Receives the imperative { open, close } handle for the host CTA. */\n onReady?: (controller: FeedbackController) => void;\n /** Alt delivery: a ref object that receives the controller. */\n controllerRef?: { current: FeedbackController | null };\n /** Injected fetch (tests / RN polyfills). */\n fetchImpl?: typeof fetch;\n /**\n * 0.2.7 — how the SDK handles the Terms of Service prompt:\n * - `\"modal\"` (DEFAULT, backwards-compatible): the built-in modal\n * renders the SDK's ToS step on first open; tapping Accept calls\n * `POST /v1/feedback/tos` and persists the token bundle.\n * - `\"skip\"`: the built-in modal SKIPS the ToS step entirely. The\n * host promises to handle consent externally (e.g. their own\n * branded ToS page) and must call `controller.acceptTos()` to\n * mint the session bundle. Until that runs, every `getStrings()`\n * surfaces the existing 401 error state (the SDK does NOT\n * silently auto-accept on the user's behalf).\n */\n tos?: \"modal\" | \"skip\";\n /**\n * 0.2.7 — host's project API key (`vrb_live_…`, scope `project:read`).\n * Lets the SDK call `GET /v1/projects/{p}/feedback-addon/state` at\n * `setup()` time + on language change, BEFORE the end-user accepts\n * ToS. When omitted, addon-state is deferred until the user's\n * session bearer is minted via `acceptTos()`, and `controller.isActive`\n * stays `null` until then.\n */\n apiKey?: string;\n /**\n * 0.2.7 — controls `controller.isActive` (which hosts wire to their\n * own CTA's `hidden` flag — the SDK does not render a floating CTA\n * itself; see plugin.tsx header).\n * - `\"auto\"` (DEFAULT): mirrors `state.isActive` from the backend,\n * i.e. hides on Starter / no SKU, shows on Unlimited.\n * - `\"show\"`: forces `isActive=true` regardless of state.\n * - `\"hide\"`: forces `isActive=false` regardless of state.\n */\n cta?: \"auto\" | \"show\" | \"hide\";\n /**\n * 0.2.7 — what the panel calls \"on-screen keys\":\n * - `\"current-view\"` (DEFAULT — rebalances the 1.0.3 \"strict-better-\n * than-false-empty\" trade-off now that we have a better mechanism):\n * `controller.open()` (now async) resets the on-screen key\n * registry, force-emits a same-locale `languageChanged` through\n * i18next to re-render every consumer, awaits a 1-frame yield so\n * their `t()` calls repopulate the registry, snapshots, then opens\n * the modal with that snapshot. Result: the widget lists ONLY keys\n * rendered on the user's current screen at the instant they\n * opened it.\n * - `\"all\"`: pre-0.2.7 behavior — no reset, no force re-render. The\n * panel reads the accumulated registry as-is (still strictly\n * better than a false-empty; useful if you want the cumulative\n * keys-since-mount view).\n *\n * KNOWN LIMITATION (`\"current-view\"`): components that call\n * `i18next.t()` OUTSIDE a React tree (cron-like calls, module-load\n * `t()`) are NOT re-collected by the changeLanguage trigger — they\n * have nothing to re-render. Acceptable: those calls aren't strictly\n * \"rendered on screen\", so excluding them matches the on-screen\n * contract.\n */\n scope?: \"current-view\" | \"all\";\n}\n\n/** 0.2.7 — panel store. `snapshotKeys` is the on-screen snapshot captured\n * by `controller.open()` when `scope: \"current-view\"`; the Outlet passes\n * it down as the panel's `keys` prop, where the precedence becomes\n * `options.keys` > snapshot > registry. `getSnapshot` must return a\n * stable reference between notifications (the useSyncExternalStore\n * contract), so the state object is reallocated only on real changes. */\ntype PanelState = {\n isOpen: boolean;\n snapshotKeys: DeclaredKey[] | undefined;\n};\nfunction makeStore() {\n let state: PanelState = { isOpen: false, snapshotKeys: undefined };\n const listeners = new Set<() => void>();\n return {\n getState: (): PanelState => state,\n setOpen(open: boolean, snapshotKeys?: DeclaredKey[]): void {\n const next: PanelState = {\n isOpen: open,\n snapshotKeys: open ? snapshotKeys : undefined,\n };\n if (\n state.isOpen === next.isOpen &&\n state.snapshotKeys === next.snapshotKeys\n ) {\n return;\n }\n state = next;\n listeners.forEach((l) => l());\n },\n subscribe(l: () => void): () => void {\n listeners.add(l);\n return () => {\n listeners.delete(l);\n };\n },\n };\n}\n\n/**\n * 0.2.7 — \"current-view\" snapshot capture. Resets the on-screen key\n * registry, forces every i18next-bound consumer to re-render via a\n * same-locale `changeLanguage` (which re-emits `languageChanged` without\n * triggering a CDN re-fetch — same locale value), awaits a 1-frame\n * yield (rAF on web / 16 ms setTimeout on RN/Hermes) so the re-renders'\n * `t()` calls repopulate the registry, then snapshots. When the i18next\n * instance isn't reachable (non-Verbumia host) returns the current\n * registry snapshot WITHOUT the reset+rerender — strictly better than a\n * false-empty, matches the pre-0.2.7 `scope: \"all\"` behavior.\n */\nasync function captureCurrentViewSnapshot(\n i18next:\n | {\n language?: string;\n changeLanguage?: (lng: string) => Promise<unknown>;\n }\n | undefined,\n): Promise<DeclaredKey[]> {\n type Reg = {\n snapshot: () => DeclaredKey[];\n reset?: () => void;\n };\n const reg = (globalThis as Record<string, unknown>)\n .__verbumia_key_registry__ as Reg | undefined;\n if (!reg) return [];\n const lng = i18next?.language;\n const change = i18next?.changeLanguage;\n if (typeof change === \"function\" && typeof lng === \"string\" && lng) {\n // Reset BEFORE the trigger so the post-rerender repopulation is the\n // only contributor to the snapshot.\n reg.reset?.();\n try {\n await change(lng);\n } catch {\n // Defensive — if i18next throws (e.g. exotic backend), fall\n // through to the no-trigger path: snapshot whatever's there.\n }\n await new Promise<void>((r) => {\n if (typeof requestAnimationFrame === \"function\") {\n requestAnimationFrame(() => r());\n } else {\n setTimeout(() => r(), 16);\n }\n });\n return reg.snapshot();\n }\n // No reachable i18next — degrade to \"snapshot the accumulator\". Avoids\n // a guaranteed false-empty on non-Verbumia hosts.\n return reg.snapshot();\n}\n\nexport function feedbackPlugin(options: FeedbackPluginOptions): I18nPlugin {\n const store = makeStore();\n let client: FeedbackClient | null = null;\n\n function Outlet() {\n const state = useSyncExternalStore(\n store.subscribe,\n store.getState,\n store.getState,\n );\n if (!state.isOpen || !client || typeof document === \"undefined\") return null;\n const c = client;\n // Precedence: explicit `options.keys` from the host wins (the\n // CONTRACT v5 \"authoritative declaration\"); else the 0.2.7\n // `scope: \"current-view\"` snapshot captured by `controller.open()`;\n // else the panel falls back to the live registry (the pre-0.2.7\n // path, equivalent to `scope: \"all\"`).\n const panelKeys = options.keys ?? state.snapshotKeys;\n return createPortal(\n <FeedbackPanel\n client={c}\n keys={panelKeys}\n namespace={options.namespace}\n tos={options.tos ?? \"modal\"}\n onClose={() => {\n store.setOpen(false);\n void c.flush();\n }}\n />,\n document.body,\n );\n }\n\n return {\n name: \"@verbumia/feedback\",\n setup(ctx) {\n // #806 SeedSower lang-change init source: prefer an explicit\n // plugin option, then the i18n provider's CURRENT language (so\n // mounts that happen after a boot-time language flip get it\n // right), then the boot-snapshot defaultLocale as the floor.\n const initialLanguage =\n options.language ?? ctx.i18n?.language ?? ctx.config.defaultLocale;\n // 0.2.7 ToS: `tos: \"skip\"` flips `autoAcceptTos: false` so the\n // client refuses to silently `POST /v1/feedback/tos` on the user's\n // behalf — the host owns acceptance via `controller.acceptTos()`.\n const tos: \"modal\" | \"skip\" = options.tos ?? \"modal\";\n client = new FeedbackClient({\n apiBase:\n options.apiBase ?? ctx.config.apiBase ?? \"https://api.verbumia.dev\",\n projectId: options.projectId ?? ctx.config.projectUuid,\n language: initialLanguage,\n endUserId: options.endUserId,\n fetchImpl: options.fetchImpl,\n apiKey: options.apiKey,\n autoAcceptTos: tos !== \"skip\",\n });\n const clientRef = client;\n // 0.2.7 addon-state cache. `null` until the first fetch resolves;\n // a transient transport error leaves the prior value in place\n // (best-effort — never flip the host's CTA on a network blip).\n let addonState: FeedbackAddonState | null = null;\n const cta: \"auto\" | \"show\" | \"hide\" = options.cta ?? \"auto\";\n // 0.2.7 — `scope: \"current-view\"` is the new default. The\n // accumulator behavior from 1.0.3 stays available behind\n // `scope: \"all\"` for hosts that want it (or for non-Verbumia hosts\n // where the changeLanguage trigger can't reach an i18next).\n const scope: \"current-view\" | \"all\" = options.scope ?? \"current-view\";\n const ctxI18next = ctx.i18n?.i18next;\n const refreshState = async (): Promise<void> => {\n try {\n const next = await clientRef.getAddonState();\n if (next !== null) addonState = next;\n } catch {\n // Pre-acceptTos + no apiKey → getAddonState throws synchronously\n // (\"addon state requires apiKey or acceptTos\"). Defer silently;\n // a later acceptTos() will let the plugin retry.\n }\n };\n // Fetch state at setup time when an apiKey is available. Without\n // one, the controller getters stay `null` until the host calls\n // controller.acceptTos() (or the modal does), at which point the\n // bearer path unblocks the fetch — wired below.\n if (options.apiKey) void refreshState();\n // #806 — re-sync the client (and refresh addon-state, since\n // enabledLanguages may differ by language) on runtime language\n // changes. Optional: pre-1.0.5 hosts no-op.\n let langUnsub: (() => void) | undefined;\n if (typeof ctx.onLanguageChange === \"function\") {\n langUnsub = ctx.onLanguageChange((lng) => {\n clientRef.setLanguage(lng);\n void refreshState();\n });\n }\n const controller: FeedbackController = {\n open: async (): Promise<void> => {\n if (scope === \"current-view\") {\n const snapshot = await captureCurrentViewSnapshot(ctxI18next);\n store.setOpen(true, snapshot);\n return;\n }\n // scope === \"all\" → pre-0.2.7 behavior: no reset, no rerender,\n // no snapshot — the panel reads the live registry directly.\n store.setOpen(true);\n },\n close: () => {\n store.setOpen(false);\n void clientRef?.flush();\n },\n client: clientRef,\n get tosVersion(): string {\n return clientRef.tosVersion;\n },\n get hasAcceptedTos(): boolean {\n return clientRef.hasAcceptedTos;\n },\n acceptTos: async (): Promise<void> => {\n await clientRef.acceptTos();\n // Newly-minted bearer unblocks the deferred state fetch.\n if (!options.apiKey) await refreshState();\n },\n get isActive(): boolean | null {\n if (cta === \"show\") return true;\n if (cta === \"hide\") return false;\n // cta === \"auto\"\n return addonState ? addonState.isActive : null;\n },\n get enabledLanguages(): string[] | \"all\" | null {\n return addonState ? addonState.enabledLanguages : null;\n },\n get sku(): \"feedback_starter\" | \"feedback_unlimited\" | null {\n return addonState ? addonState.sku : null;\n },\n };\n options.onReady?.(controller);\n if (options.controllerRef) options.controllerRef.current = controller;\n return () => {\n langUnsub?.();\n if (options.controllerRef) options.controllerRef.current = null;\n void client?.flush();\n };\n },\n render: () => <Outlet />,\n };\n}\n","import { useCallback, useEffect, useState } from \"react\";\n\nimport type { FeedbackClient } from \"../core/client\";\nimport { resolveKeys } from \"../core/keys\";\nimport { t as tr } from \"../core/locales\";\nimport type { DeclaredKey, FeedbackString } from \"../core/types\";\n\nconst C = {\n bg: \"#0b0f0e\",\n panel: \"#111714\",\n border: \"#1f2a25\",\n text: \"#e7f5ef\",\n dim: \"#8aa79b\",\n emerald: \"#10b981\",\n emeraldSoft: \"#34d399\",\n};\n\nexport function FeedbackPanel(props: {\n client: FeedbackClient;\n keys?: DeclaredKey[];\n namespace?: string | string[];\n /** 0.2.7 — `\"skip\"` removes the built-in ToS step entirely; the host\n * owns consent (via `controller.acceptTos()`). When the user has not\n * yet accepted, the strings fetch surfaces a 401-derived error in the\n * existing error row. */\n tos?: \"modal\" | \"skip\";\n onClose: () => void;\n}) {\n const { client, keys, namespace, tos = \"modal\", onClose } = props;\n // `tos: \"skip\"` short-circuits the local consent state so the panel\n // renders the strings section directly. The client refuses to\n // auto-acceptTos (autoAcceptTos:false on the plugin side), so a\n // missing token surfaces as the existing 401 error row.\n const [consented, setConsented] = useState(\n tos === \"skip\" ? true : client.hasConsented,\n );\n const [busy, setBusy] = useState(false);\n const [error, setError] = useState<string | null>(null);\n const [strings, setStrings] = useState<FeedbackString[]>([]);\n // 0.2.8 — \"Show original\" toggle. OFF by default; user opt-in to see\n // the source-locale rendering above each translated string. Local to\n // the panel mount — not persisted across opens (each open is a fresh\n // review session per the scope:\"current-view\" 0.2.7 model).\n const [showSource, setShowSource] = useState<boolean>(false);\n\n const loadStrings = useCallback(async () => {\n setBusy(true);\n setError(null);\n try {\n // On-screen scoping is NORMATIVE (spec ltm 373, CONTRACT v5): show\n // ONLY rendered keys. Empty -> \"no strings on this view\"; never\n // fall back to fetching the whole project.\n const resolved = resolveKeys(keys, namespace);\n if (!resolved.length) {\n // #806 SeedSower P1 — when the panel opens with NO explicit\n // `keys` prop (i.e. it depended on the i18n SDK's on-screen\n // registry), an empty resolve almost always means the host's\n // `useTranslation` imports went to `react-i18next` instead of\n // `@verbumia/react-i18next`. Either way, the keys never fed the\n // registry. Log a single, actionable hint so the integrator\n // doesn't waste a debug cycle on \"the widget is broken\".\n if (!keys || keys.length === 0) {\n // eslint-disable-next-line no-console\n console.warn(\n \"[verbumia/feedback] registry empty — are your useTranslation imports coming from @verbumia/react-i18next?\",\n );\n }\n setStrings([]);\n return;\n }\n const res = await client.getStrings({ keys: resolved });\n setStrings(res.strings);\n } catch (e) {\n setError(e instanceof Error ? e.message : \"Failed to load strings\");\n } finally {\n setBusy(false);\n }\n }, [client, keys, namespace]);\n\n useEffect(() => {\n if (consented) void loadStrings();\n }, [consented, loadStrings]);\n\n const accept = useCallback(async () => {\n setBusy(true);\n setError(null);\n try {\n await client.acceptTos();\n setConsented(true);\n } catch (e) {\n setError(e instanceof Error ? e.message : \"Could not accept the terms\");\n } finally {\n setBusy(false);\n }\n }, [client]);\n\n return (\n <div\n role=\"dialog\"\n aria-label=\"Translation feedback\"\n style={{\n position: \"fixed\",\n inset: 0,\n zIndex: 2147483600,\n background: \"rgba(0,0,0,.55)\",\n display: \"flex\",\n justifyContent: \"flex-end\",\n }}\n onClick={onClose}\n >\n <div\n onClick={(e) => e.stopPropagation()}\n style={{\n width: \"min(420px, 100%)\",\n height: \"100%\",\n background: C.bg,\n color: C.text,\n borderLeft: `1px solid ${C.border}`,\n display: \"flex\",\n flexDirection: \"column\",\n fontFamily: \"system-ui, sans-serif\",\n }}\n >\n <header\n style={{\n padding: \"16px 18px\",\n borderBottom: `1px solid ${C.border}`,\n display: \"flex\",\n justifyContent: \"space-between\",\n alignItems: \"center\",\n }}\n >\n <strong style={{ color: C.emeraldSoft }}>Translation feedback</strong>\n <button\n type=\"button\"\n onClick={onClose}\n aria-label=\"Close\"\n style={{\n background: \"transparent\",\n color: C.dim,\n border: \"none\",\n fontSize: 20,\n cursor: \"pointer\",\n }}\n >\n ×\n </button>\n </header>\n\n <div style={{ padding: 18, overflowY: \"auto\", flex: 1 }}>\n {error && (\n <p style={{ color: \"#f87171\", fontSize: 13 }}>{error}</p>\n )}\n\n {!consented ? (\n <ConsentStep\n version={client.tosVersion}\n busy={busy}\n onAccept={accept}\n />\n ) : busy && !strings.length ? (\n <p style={{ color: C.dim }}>Loading…</p>\n ) : !strings.length ? (\n <p style={{ color: C.dim }}>No strings to review on this view.</p>\n ) : (\n <>\n {/* 0.2.8 — source-language toggle at the top of the strings\n list. Bundled labels en/fr/es/de (core/locales.ts);\n falls back to en when the user's language has no entry. */}\n <div\n style={{\n display: \"flex\",\n alignItems: \"center\",\n justifyContent: \"flex-end\",\n marginBottom: 10,\n }}\n >\n <label\n style={{\n display: \"inline-flex\",\n alignItems: \"center\",\n gap: 8,\n color: C.dim,\n fontSize: 12,\n cursor: \"pointer\",\n }}\n >\n <input\n type=\"checkbox\"\n checked={showSource}\n onChange={(e) => setShowSource(e.target.checked)}\n />\n {tr(client.language, \"showOriginal\")}\n </label>\n </div>\n {strings.map((s) => (\n <StringRow\n key={`${s.namespace}:${s.key}`}\n s={s}\n client={client}\n showSource={showSource}\n />\n ))}\n </>\n )}\n </div>\n <footer\n style={{\n padding: \"10px 18px\",\n borderTop: `1px solid ${C.border}`,\n color: C.dim,\n fontSize: 11,\n }}\n >\n Powered by Verbumia\n </footer>\n </div>\n </div>\n );\n}\n\nfunction ConsentStep(props: {\n version?: string;\n busy: boolean;\n onAccept: () => void;\n}) {\n return (\n <div>\n <p style={{ lineHeight: 1.5, fontSize: 14 }}>\n Help improve the translations in this app. Your ratings and\n suggestions are sent to the app owner via Verbumia. Don’t submit\n personal or sensitive information.\n </p>\n <button\n type=\"button\"\n disabled={props.busy}\n onClick={props.onAccept}\n style={{\n marginTop: 12,\n width: \"100%\",\n background: C.emerald,\n color: \"#03110c\",\n border: \"none\",\n borderRadius: 8,\n padding: \"12px 14px\",\n fontWeight: 700,\n cursor: props.busy ? \"default\" : \"pointer\",\n opacity: props.busy ? 0.6 : 1,\n }}\n >\n {props.busy\n ? \"…\"\n : `I accept the terms${props.version ? ` (v${props.version})` : \"\"}`}\n </button>\n </div>\n );\n}\n\nfunction StringRow(props: {\n s: FeedbackString;\n client: FeedbackClient;\n /** 0.2.8 — whether the panel-level \"Show original\" toggle is on.\n * The source row is only rendered when this is `true` AND the\n * source locale differs from the target locale (so a single-locale\n * project doesn't display the same string twice). */\n showSource: boolean;\n}) {\n const { s, client, showSource } = props;\n const [mine, setMine] = useState<number | null>(s.my_rating);\n const [showSuggest, setShowSuggest] = useState(false);\n // 0.2.8 — when an existing `my_suggestion` came back from /strings,\n // the editor pre-fills with that text and the submit path switches\n // from POST to PATCH. The id is captured here (not in useState) so\n // it's stable for the lifetime of this StringRow mount; the batched\n // POST endpoint doesn't return per-item ids today, so we don't\n // promote a fresh create into edit mode mid-session — the user gets\n // edit mode on the NEXT panel open (when /strings re-returns the\n // server-persisted my_suggestion).\n const mySuggestionId: string | null = s.my_suggestion\n ? s.my_suggestion.id\n : null;\n const [text, setText] = useState<string>(s.my_suggestion?.text ?? \"\");\n const [sent, setSent] = useState(false);\n const [editError, setEditError] = useState<string | null>(null);\n\n const rate = (stars: number) => {\n setMine(stars);\n client.rate({\n namespace: s.namespace,\n key: s.key,\n language: client.language,\n translation_hash: s.translation_hash,\n stars,\n });\n };\n\n const submitSuggestion = async () => {\n const trimmed = text.trim();\n if (!trimmed) return;\n setEditError(null);\n try {\n if (mySuggestionId) {\n // 0.2.8 edit path — PATCH /v1/feedback/suggestions/{id}.\n // Synchronous round-trip (not the batched queue) so the user\n // gets immediate confirmation of the edit landing.\n await client.editSuggestion(mySuggestionId, trimmed);\n } else {\n // Legacy create path — batched (queued + debounced).\n client.suggest({\n namespace: s.namespace,\n key: s.key,\n language: client.language,\n translation_hash: s.translation_hash,\n suggested_text: trimmed,\n });\n }\n setSent(true);\n setShowSuggest(false);\n } catch (e) {\n setEditError(\n e instanceof Error ? e.message : \"Could not update the suggestion\",\n );\n }\n };\n\n // 0.2.8 source row — only shown when the toggle is on AND we'd be\n // displaying something the user can't already see in `s.value`.\n const showSourceRow = showSource && s.source_locale !== client.language;\n // Submit label adapts to the mode the editor is in. Bundled in\n // core/locales.ts so the SDK ships labels for at least en/fr/es.\n const submitLabel = tr(\n client.language,\n mySuggestionId ? \"updateMySuggestion\" : \"submitSuggestion\",\n );\n\n return (\n <div\n style={{\n background: C.panel,\n border: `1px solid ${C.border}`,\n borderRadius: 10,\n padding: 12,\n marginBottom: 10,\n }}\n >\n <div style={{ fontSize: 12, color: C.dim }}>\n {s.namespace} · {s.key}\n </div>\n {showSourceRow ? (\n <div\n data-testid=\"source-row\"\n style={{\n margin: \"4px 0 2px\",\n fontSize: 12,\n color: C.dim,\n fontStyle: \"italic\",\n }}\n >\n {s.source_text}\n </div>\n ) : null}\n <div style={{ margin: \"6px 0 10px\", fontSize: 15 }}>{s.value}</div>\n <div style={{ display: \"flex\", gap: 4 }}>\n {[1, 2, 3, 4, 5].map((n) => (\n <button\n key={n}\n type=\"button\"\n aria-label={`${n} star${n > 1 ? \"s\" : \"\"}`}\n onClick={() => rate(n)}\n style={{\n background: \"transparent\",\n border: \"none\",\n cursor: \"pointer\",\n fontSize: 20,\n color: mine && n <= mine ? C.emeraldSoft : C.border,\n }}\n >\n ★\n </button>\n ))}\n <button\n type=\"button\"\n onClick={() => setShowSuggest((v) => !v)}\n style={{\n marginLeft: \"auto\",\n background: \"transparent\",\n color: C.emeraldSoft,\n border: `1px solid ${C.border}`,\n borderRadius: 6,\n padding: \"4px 10px\",\n fontSize: 12,\n cursor: \"pointer\",\n }}\n >\n {sent ? \"Suggested ✓\" : submitLabel}\n </button>\n </div>\n {showSuggest && (\n <div style={{ marginTop: 10 }}>\n <textarea\n value={text}\n onChange={(e) => setText(e.target.value)}\n rows={3}\n placeholder=\"Your suggested translation…\"\n style={{\n width: \"100%\",\n background: C.bg,\n color: C.text,\n border: `1px solid ${C.border}`,\n borderRadius: 6,\n padding: 8,\n fontSize: 14,\n resize: \"vertical\",\n }}\n />\n {editError ? (\n <p style={{ color: \"#f87171\", fontSize: 12, marginTop: 6 }}>\n {editError}\n </p>\n ) : null}\n <button\n type=\"button\"\n onClick={() => void submitSuggestion()}\n style={{\n marginTop: 6,\n background: C.emerald,\n color: \"#03110c\",\n border: \"none\",\n borderRadius: 6,\n padding: \"8px 12px\",\n fontWeight: 700,\n cursor: \"pointer\",\n }}\n >\n {submitLabel}\n </button>\n </div>\n )}\n </div>\n );\n}\n"],"mappings":"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","names":["jsx"]}
|
package/dist/svelte/index.cjs
CHANGED
|
@@ -75,11 +75,61 @@ var FeedbackClient = class {
|
|
|
75
75
|
get hasConsented() {
|
|
76
76
|
return this.tokens !== null;
|
|
77
77
|
}
|
|
78
|
+
/** Alias of `hasConsented` exposed under the 0.2.7 naming used by the
|
|
79
|
+
* `controller.hasAcceptedTos` getter (the SDK's built-in modal and a
|
|
80
|
+
* host's external ToS page share the same persisted token bundle, so
|
|
81
|
+
* both flip this boolean once a session is bootstrapped). */
|
|
82
|
+
get hasAcceptedTos() {
|
|
83
|
+
return this.tokens !== null;
|
|
84
|
+
}
|
|
78
85
|
/** Server-minted sessionId / grouping_key (from the token bundle).
|
|
79
86
|
* Available only after `acceptTos()`; never client-generated. */
|
|
80
87
|
get sessionId() {
|
|
81
88
|
return this.tokens?.grouping_key;
|
|
82
89
|
}
|
|
90
|
+
/**
|
|
91
|
+
* 0.2.7 — `GET /v1/projects/{projectId}/feedback-addon/state`.
|
|
92
|
+
*
|
|
93
|
+
* Auth selection (matches backend's dual-acceptance after task 836's
|
|
94
|
+
* follow-up PR — see CONTRACT note in the response type):
|
|
95
|
+
* - If `cfg.apiKey` is set → `Authorization: ApiKey <key>` (so the
|
|
96
|
+
* plugin can fetch state at `setup()` time, before the user has
|
|
97
|
+
* accepted ToS).
|
|
98
|
+
* - Else if a user-session bundle exists → `Authorization: Bearer
|
|
99
|
+
* <access_token>` (the deferred-after-acceptTos path).
|
|
100
|
+
* - Else → throw `FeedbackError("addon state requires apiKey or
|
|
101
|
+
* acceptTos")`. The plugin's setup catches and surfaces a
|
|
102
|
+
* console.warn the first time, then re-attempts on the
|
|
103
|
+
* bundle-mint that follows `acceptTos()`.
|
|
104
|
+
*
|
|
105
|
+
* Best-effort: a transport error returns `null` instead of throwing,
|
|
106
|
+
* so the controller's `isActive` falls back to whatever was last
|
|
107
|
+
* known (or its initial value) without flipping the host's CTA into
|
|
108
|
+
* an inconsistent state on a transient blip.
|
|
109
|
+
*/
|
|
110
|
+
async getAddonState() {
|
|
111
|
+
let auth;
|
|
112
|
+
if (this.cfg.apiKey) {
|
|
113
|
+
auth = `ApiKey ${this.cfg.apiKey}`;
|
|
114
|
+
} else if (this.tokens) {
|
|
115
|
+
auth = `Bearer ${this.tokens.access_token}`;
|
|
116
|
+
} else {
|
|
117
|
+
throw new FeedbackError(
|
|
118
|
+
"addon state requires apiKey or acceptTos"
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
const url = `${this.base()}/v1/projects/${this.cfg.projectId}/feedback-addon/state`;
|
|
122
|
+
try {
|
|
123
|
+
const res = await this.fetchImpl(url, {
|
|
124
|
+
method: "GET",
|
|
125
|
+
headers: { Authorization: auth }
|
|
126
|
+
});
|
|
127
|
+
if (!res.ok) return null;
|
|
128
|
+
return await res.json();
|
|
129
|
+
} catch {
|
|
130
|
+
return null;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
83
133
|
/** BCP-47 language the widget is rating strings in. */
|
|
84
134
|
get language() {
|
|
85
135
|
return this.cfg.language;
|
|
@@ -151,9 +201,18 @@ var FeedbackClient = class {
|
|
|
151
201
|
}
|
|
152
202
|
this.tokens = await res.json();
|
|
153
203
|
}
|
|
154
|
-
/** Authenticated fetch with a single transparent refresh-on-401 retry.
|
|
204
|
+
/** Authenticated fetch with a single transparent refresh-on-401 retry.
|
|
205
|
+
* When `cfg.autoAcceptTos === false` (the plugin's `tos: "skip"` opt-in
|
|
206
|
+
* / 0.2.7) and no token has been minted yet, throws a `not consented`
|
|
207
|
+
* error instead of silently calling `acceptTos()` — the host promises
|
|
208
|
+
* to handle consent externally via `controller.acceptTos()`. */
|
|
155
209
|
async authed(path, init, retry = true) {
|
|
156
|
-
if (!this.tokens)
|
|
210
|
+
if (!this.tokens) {
|
|
211
|
+
if (this.cfg.autoAcceptTos === false) {
|
|
212
|
+
throw new FeedbackError("not consented");
|
|
213
|
+
}
|
|
214
|
+
await this.acceptTos();
|
|
215
|
+
}
|
|
157
216
|
const res = await this.fetchImpl(`${this.base()}${path}`, {
|
|
158
217
|
...init,
|
|
159
218
|
headers: {
|
|
@@ -190,6 +249,31 @@ var FeedbackClient = class {
|
|
|
190
249
|
suggest(payload) {
|
|
191
250
|
this.enqueue({ kind: "suggestion", payload });
|
|
192
251
|
}
|
|
252
|
+
/**
|
|
253
|
+
* 0.2.8 — `PATCH /v1/feedback/suggestions/{id}` (backend task 847,
|
|
254
|
+
* deploy `45190c8`). Used by the panel's edit-mode editor when the
|
|
255
|
+
* end-user already has a pending suggestion for a string (the
|
|
256
|
+
* `FeedbackString.my_suggestion.id` from a prior submit). Submits
|
|
257
|
+
* synchronously rather than going through the rating/suggestion
|
|
258
|
+
* batch queue — the host triggered an explicit edit, not a passive
|
|
259
|
+
* rating, so we want the round-trip to surface before the panel
|
|
260
|
+
* closes. Best-effort failures bubble; the caller catches and
|
|
261
|
+
* shows an error row in the panel.
|
|
262
|
+
*
|
|
263
|
+
* Wire body: `{ text: string }` (backend probe confirmed). NOT the
|
|
264
|
+
* legacy `{ suggested_text }` of the batched POST path.
|
|
265
|
+
*/
|
|
266
|
+
async editSuggestion(id, text) {
|
|
267
|
+
if (!id) throw new FeedbackError("editSuggestion: id is required");
|
|
268
|
+
const trimmed = (text ?? "").trim();
|
|
269
|
+
if (!trimmed) throw new FeedbackError("editSuggestion: text is required");
|
|
270
|
+
const res = await this.authed(`/v1/feedback/suggestions/${encodeURIComponent(id)}`, {
|
|
271
|
+
method: "PATCH",
|
|
272
|
+
headers: { "Content-Type": "application/json" },
|
|
273
|
+
body: JSON.stringify({ text: trimmed })
|
|
274
|
+
});
|
|
275
|
+
if (!res.ok) throw await this.problem(res, "failed to update suggestion");
|
|
276
|
+
}
|
|
193
277
|
enqueue(item) {
|
|
194
278
|
this.queue.push(item);
|
|
195
279
|
if (this.queue.length >= this.cfg.maxBatch) {
|