@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.
Files changed (37) hide show
  1. package/dist/chunk-EKU6NNWI.js +40 -0
  2. package/dist/chunk-EKU6NNWI.js.map +1 -0
  3. package/dist/{chunk-5RTFWOGT.js → chunk-EMGN6MR4.js} +87 -3
  4. package/dist/chunk-EMGN6MR4.js.map +1 -0
  5. package/dist/{client-qgDSbz3A.d.cts → client-CnEK_2SD.d.cts} +114 -1
  6. package/dist/{client-qgDSbz3A.d.ts → client-CnEK_2SD.d.ts} +114 -1
  7. package/dist/core/index.cjs +86 -2
  8. package/dist/core/index.cjs.map +1 -1
  9. package/dist/core/index.d.cts +2 -2
  10. package/dist/core/index.d.ts +2 -2
  11. package/dist/core/index.js +1 -1
  12. package/dist/{keys-BhuK_fy1.d.cts → keys-2_T5bDpX.d.cts} +1 -1
  13. package/dist/{keys-CEWu0Htb.d.ts → keys-eHc_lx5v.d.ts} +1 -1
  14. package/dist/native/index.cjs +317 -48
  15. package/dist/native/index.cjs.map +1 -1
  16. package/dist/native/index.d.cts +48 -4
  17. package/dist/native/index.d.ts +48 -4
  18. package/dist/native/index.js +200 -48
  19. package/dist/native/index.js.map +1 -1
  20. package/dist/react/index.cjs +319 -41
  21. package/dist/react/index.cjs.map +1 -1
  22. package/dist/react/index.d.cts +119 -4
  23. package/dist/react/index.d.ts +119 -4
  24. package/dist/react/index.js +202 -41
  25. package/dist/react/index.js.map +1 -1
  26. package/dist/svelte/index.cjs +86 -2
  27. package/dist/svelte/index.cjs.map +1 -1
  28. package/dist/svelte/index.d.cts +2 -2
  29. package/dist/svelte/index.d.ts +2 -2
  30. package/dist/svelte/index.js +1 -1
  31. package/dist/vue/index.cjs +86 -2
  32. package/dist/vue/index.cjs.map +1 -1
  33. package/dist/vue/index.d.cts +2 -2
  34. package/dist/vue/index.d.ts +2 -2
  35. package/dist/vue/index.js +1 -1
  36. package/package.json +1 -1
  37. package/dist/chunk-5RTFWOGT.js.map +0 -1
@@ -81,11 +81,61 @@ var FeedbackClient = class {
81
81
  get hasConsented() {
82
82
  return this.tokens !== null;
83
83
  }
84
+ /** Alias of `hasConsented` exposed under the 0.2.7 naming used by the
85
+ * `controller.hasAcceptedTos` getter (the SDK's built-in modal and a
86
+ * host's external ToS page share the same persisted token bundle, so
87
+ * both flip this boolean once a session is bootstrapped). */
88
+ get hasAcceptedTos() {
89
+ return this.tokens !== null;
90
+ }
84
91
  /** Server-minted sessionId / grouping_key (from the token bundle).
85
92
  * Available only after `acceptTos()`; never client-generated. */
86
93
  get sessionId() {
87
94
  return this.tokens?.grouping_key;
88
95
  }
96
+ /**
97
+ * 0.2.7 — `GET /v1/projects/{projectId}/feedback-addon/state`.
98
+ *
99
+ * Auth selection (matches backend's dual-acceptance after task 836's
100
+ * follow-up PR — see CONTRACT note in the response type):
101
+ * - If `cfg.apiKey` is set → `Authorization: ApiKey <key>` (so the
102
+ * plugin can fetch state at `setup()` time, before the user has
103
+ * accepted ToS).
104
+ * - Else if a user-session bundle exists → `Authorization: Bearer
105
+ * <access_token>` (the deferred-after-acceptTos path).
106
+ * - Else → throw `FeedbackError("addon state requires apiKey or
107
+ * acceptTos")`. The plugin's setup catches and surfaces a
108
+ * console.warn the first time, then re-attempts on the
109
+ * bundle-mint that follows `acceptTos()`.
110
+ *
111
+ * Best-effort: a transport error returns `null` instead of throwing,
112
+ * so the controller's `isActive` falls back to whatever was last
113
+ * known (or its initial value) without flipping the host's CTA into
114
+ * an inconsistent state on a transient blip.
115
+ */
116
+ async getAddonState() {
117
+ let auth;
118
+ if (this.cfg.apiKey) {
119
+ auth = `ApiKey ${this.cfg.apiKey}`;
120
+ } else if (this.tokens) {
121
+ auth = `Bearer ${this.tokens.access_token}`;
122
+ } else {
123
+ throw new FeedbackError(
124
+ "addon state requires apiKey or acceptTos"
125
+ );
126
+ }
127
+ const url = `${this.base()}/v1/projects/${this.cfg.projectId}/feedback-addon/state`;
128
+ try {
129
+ const res = await this.fetchImpl(url, {
130
+ method: "GET",
131
+ headers: { Authorization: auth }
132
+ });
133
+ if (!res.ok) return null;
134
+ return await res.json();
135
+ } catch {
136
+ return null;
137
+ }
138
+ }
89
139
  /** BCP-47 language the widget is rating strings in. */
90
140
  get language() {
91
141
  return this.cfg.language;
@@ -157,9 +207,18 @@ var FeedbackClient = class {
157
207
  }
158
208
  this.tokens = await res.json();
159
209
  }
160
- /** Authenticated fetch with a single transparent refresh-on-401 retry. */
210
+ /** Authenticated fetch with a single transparent refresh-on-401 retry.
211
+ * When `cfg.autoAcceptTos === false` (the plugin's `tos: "skip"` opt-in
212
+ * / 0.2.7) and no token has been minted yet, throws a `not consented`
213
+ * error instead of silently calling `acceptTos()` — the host promises
214
+ * to handle consent externally via `controller.acceptTos()`. */
161
215
  async authed(path, init, retry = true) {
162
- if (!this.tokens) await this.acceptTos();
216
+ if (!this.tokens) {
217
+ if (this.cfg.autoAcceptTos === false) {
218
+ throw new FeedbackError("not consented");
219
+ }
220
+ await this.acceptTos();
221
+ }
163
222
  const res = await this.fetchImpl(`${this.base()}${path}`, {
164
223
  ...init,
165
224
  headers: {
@@ -196,6 +255,31 @@ var FeedbackClient = class {
196
255
  suggest(payload) {
197
256
  this.enqueue({ kind: "suggestion", payload });
198
257
  }
258
+ /**
259
+ * 0.2.8 — `PATCH /v1/feedback/suggestions/{id}` (backend task 847,
260
+ * deploy `45190c8`). Used by the panel's edit-mode editor when the
261
+ * end-user already has a pending suggestion for a string (the
262
+ * `FeedbackString.my_suggestion.id` from a prior submit). Submits
263
+ * synchronously rather than going through the rating/suggestion
264
+ * batch queue — the host triggered an explicit edit, not a passive
265
+ * rating, so we want the round-trip to surface before the panel
266
+ * closes. Best-effort failures bubble; the caller catches and
267
+ * shows an error row in the panel.
268
+ *
269
+ * Wire body: `{ text: string }` (backend probe confirmed). NOT the
270
+ * legacy `{ suggested_text }` of the batched POST path.
271
+ */
272
+ async editSuggestion(id, text) {
273
+ if (!id) throw new FeedbackError("editSuggestion: id is required");
274
+ const trimmed = (text ?? "").trim();
275
+ if (!trimmed) throw new FeedbackError("editSuggestion: text is required");
276
+ const res = await this.authed(`/v1/feedback/suggestions/${encodeURIComponent(id)}`, {
277
+ method: "PATCH",
278
+ headers: { "Content-Type": "application/json" },
279
+ body: JSON.stringify({ text: trimmed })
280
+ });
281
+ if (!res.ok) throw await this.problem(res, "failed to update suggestion");
282
+ }
199
283
  enqueue(item) {
200
284
  this.queue.push(item);
201
285
  if (this.queue.length >= this.cfg.maxBatch) {
@@ -291,6 +375,42 @@ function dedupe(keys) {
291
375
  return out;
292
376
  }
293
377
 
378
+ // src/core/locales.ts
379
+ var MESSAGES = {
380
+ en: {
381
+ showOriginal: "Show original",
382
+ submitSuggestion: "Submit a suggestion",
383
+ updateMySuggestion: "Update my suggestion"
384
+ },
385
+ fr: {
386
+ showOriginal: "Voir l'original",
387
+ submitSuggestion: "Soumettre une suggestion",
388
+ updateMySuggestion: "Mettre \xE0 jour ma suggestion"
389
+ },
390
+ es: {
391
+ showOriginal: "Ver el original",
392
+ submitSuggestion: "Enviar una sugerencia",
393
+ updateMySuggestion: "Actualizar mi sugerencia"
394
+ },
395
+ de: {
396
+ showOriginal: "Original anzeigen",
397
+ submitSuggestion: "Vorschlag einreichen",
398
+ updateMySuggestion: "Meinen Vorschlag aktualisieren"
399
+ }
400
+ };
401
+ function t(locale, key) {
402
+ if (locale) {
403
+ const exact = MESSAGES[locale];
404
+ if (exact) return exact[key];
405
+ const base = locale.split("-")[0];
406
+ if (base) {
407
+ const baseMsgs = MESSAGES[base];
408
+ if (baseMsgs) return baseMsgs[key];
409
+ }
410
+ }
411
+ return MESSAGES.en[key];
412
+ }
413
+
294
414
  // src/react/panel.tsx
295
415
  var import_jsx_runtime = require("react/jsx-runtime");
296
416
  var C = {
@@ -303,11 +423,14 @@ var C = {
303
423
  emeraldSoft: "#34d399"
304
424
  };
305
425
  function FeedbackPanel(props) {
306
- const { client, keys, namespace, onClose } = props;
307
- const [consented, setConsented] = (0, import_react.useState)(client.hasConsented);
426
+ const { client, keys, namespace, tos = "modal", onClose } = props;
427
+ const [consented, setConsented] = (0, import_react.useState)(
428
+ tos === "skip" ? true : client.hasConsented
429
+ );
308
430
  const [busy, setBusy] = (0, import_react.useState)(false);
309
431
  const [error, setError] = (0, import_react.useState)(null);
310
432
  const [strings, setStrings] = (0, import_react.useState)([]);
433
+ const [showSource, setShowSource] = (0, import_react.useState)(false);
311
434
  const loadStrings = (0, import_react.useCallback)(async () => {
312
435
  setBusy(true);
313
436
  setError(null);
@@ -414,7 +537,52 @@ function FeedbackPanel(props) {
414
537
  busy,
415
538
  onAccept: accept
416
539
  }
417
- ) : busy && !strings.length ? /* @__PURE__ */ (0, import_jsx_runtime.jsx)("p", { style: { color: C.dim }, children: "Loading\u2026" }) : !strings.length ? /* @__PURE__ */ (0, import_jsx_runtime.jsx)("p", { style: { color: C.dim }, children: "No strings to review on this view." }) : strings.map((s) => /* @__PURE__ */ (0, import_jsx_runtime.jsx)(StringRow, { s, client }, `${s.namespace}:${s.key}`))
540
+ ) : busy && !strings.length ? /* @__PURE__ */ (0, import_jsx_runtime.jsx)("p", { style: { color: C.dim }, children: "Loading\u2026" }) : !strings.length ? /* @__PURE__ */ (0, import_jsx_runtime.jsx)("p", { style: { color: C.dim }, children: "No strings to review on this view." }) : /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_jsx_runtime.Fragment, { children: [
541
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
542
+ "div",
543
+ {
544
+ style: {
545
+ display: "flex",
546
+ alignItems: "center",
547
+ justifyContent: "flex-end",
548
+ marginBottom: 10
549
+ },
550
+ children: /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(
551
+ "label",
552
+ {
553
+ style: {
554
+ display: "inline-flex",
555
+ alignItems: "center",
556
+ gap: 8,
557
+ color: C.dim,
558
+ fontSize: 12,
559
+ cursor: "pointer"
560
+ },
561
+ children: [
562
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
563
+ "input",
564
+ {
565
+ type: "checkbox",
566
+ checked: showSource,
567
+ onChange: (e) => setShowSource(e.target.checked)
568
+ }
569
+ ),
570
+ t(client.language, "showOriginal")
571
+ ]
572
+ }
573
+ )
574
+ }
575
+ ),
576
+ strings.map((s) => /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
577
+ StringRow,
578
+ {
579
+ s,
580
+ client,
581
+ showSource
582
+ },
583
+ `${s.namespace}:${s.key}`
584
+ ))
585
+ ] })
418
586
  ] }),
419
587
  /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
420
588
  "footer",
@@ -461,11 +629,13 @@ function ConsentStep(props) {
461
629
  ] });
462
630
  }
463
631
  function StringRow(props) {
464
- const { s, client } = props;
632
+ const { s, client, showSource } = props;
465
633
  const [mine, setMine] = (0, import_react.useState)(s.my_rating);
466
634
  const [showSuggest, setShowSuggest] = (0, import_react.useState)(false);
467
- const [text, setText] = (0, import_react.useState)("");
635
+ const mySuggestionId = s.my_suggestion ? s.my_suggestion.id : null;
636
+ const [text, setText] = (0, import_react.useState)(s.my_suggestion?.text ?? "");
468
637
  const [sent, setSent] = (0, import_react.useState)(false);
638
+ const [editError, setEditError] = (0, import_react.useState)(null);
469
639
  const rate = (stars) => {
470
640
  setMine(stars);
471
641
  client.rate({
@@ -476,19 +646,35 @@ function StringRow(props) {
476
646
  stars
477
647
  });
478
648
  };
479
- const submitSuggestion = () => {
480
- if (!text.trim()) return;
481
- client.suggest({
482
- namespace: s.namespace,
483
- key: s.key,
484
- language: client.language,
485
- translation_hash: s.translation_hash,
486
- suggested_text: text.trim()
487
- });
488
- setSent(true);
489
- setShowSuggest(false);
490
- setText("");
649
+ const submitSuggestion = async () => {
650
+ const trimmed = text.trim();
651
+ if (!trimmed) return;
652
+ setEditError(null);
653
+ try {
654
+ if (mySuggestionId) {
655
+ await client.editSuggestion(mySuggestionId, trimmed);
656
+ } else {
657
+ client.suggest({
658
+ namespace: s.namespace,
659
+ key: s.key,
660
+ language: client.language,
661
+ translation_hash: s.translation_hash,
662
+ suggested_text: trimmed
663
+ });
664
+ }
665
+ setSent(true);
666
+ setShowSuggest(false);
667
+ } catch (e) {
668
+ setEditError(
669
+ e instanceof Error ? e.message : "Could not update the suggestion"
670
+ );
671
+ }
491
672
  };
673
+ const showSourceRow = showSource && s.source_locale !== client.language;
674
+ const submitLabel = t(
675
+ client.language,
676
+ mySuggestionId ? "updateMySuggestion" : "submitSuggestion"
677
+ );
492
678
  return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(
493
679
  "div",
494
680
  {
@@ -505,6 +691,19 @@ function StringRow(props) {
505
691
  " \xB7 ",
506
692
  s.key
507
693
  ] }),
694
+ showSourceRow ? /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
695
+ "div",
696
+ {
697
+ "data-testid": "source-row",
698
+ style: {
699
+ margin: "4px 0 2px",
700
+ fontSize: 12,
701
+ color: C.dim,
702
+ fontStyle: "italic"
703
+ },
704
+ children: s.source_text
705
+ }
706
+ ) : null,
508
707
  /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { style: { margin: "6px 0 10px", fontSize: 15 }, children: s.value }),
509
708
  /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { style: { display: "flex", gap: 4 }, children: [
510
709
  [1, 2, 3, 4, 5].map((n) => /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
@@ -539,7 +738,7 @@ function StringRow(props) {
539
738
  fontSize: 12,
540
739
  cursor: "pointer"
541
740
  },
542
- children: sent ? "Suggested \u2713" : "Suggest"
741
+ children: sent ? "Suggested \u2713" : submitLabel
543
742
  }
544
743
  )
545
744
  ] }),
@@ -563,11 +762,12 @@ function StringRow(props) {
563
762
  }
564
763
  }
565
764
  ),
765
+ editError ? /* @__PURE__ */ (0, import_jsx_runtime.jsx)("p", { style: { color: "#f87171", fontSize: 12, marginTop: 6 }, children: editError }) : null,
566
766
  /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
567
767
  "button",
568
768
  {
569
769
  type: "button",
570
- onClick: submitSuggestion,
770
+ onClick: () => void submitSuggestion(),
571
771
  style: {
572
772
  marginTop: 6,
573
773
  background: C.emerald,
@@ -578,7 +778,7 @@ function StringRow(props) {
578
778
  fontWeight: 700,
579
779
  cursor: "pointer"
580
780
  },
581
- children: "Send suggestion"
781
+ children: submitLabel
582
782
  }
583
783
  )
584
784
  ] })
@@ -590,42 +790,73 @@ function StringRow(props) {
590
790
  // src/react/plugin.tsx
591
791
  var import_jsx_runtime2 = require("react/jsx-runtime");
592
792
  function makeStore() {
593
- let open = false;
793
+ let state = { isOpen: false, snapshotKeys: void 0 };
594
794
  const listeners = /* @__PURE__ */ new Set();
595
795
  return {
596
- isOpen: () => open,
597
- set(v) {
598
- if (open !== v) {
599
- open = v;
600
- listeners.forEach((l) => l());
796
+ getState: () => state,
797
+ setOpen(open, snapshotKeys) {
798
+ const next = {
799
+ isOpen: open,
800
+ snapshotKeys: open ? snapshotKeys : void 0
801
+ };
802
+ if (state.isOpen === next.isOpen && state.snapshotKeys === next.snapshotKeys) {
803
+ return;
601
804
  }
805
+ state = next;
806
+ listeners.forEach((l) => l());
602
807
  },
603
808
  subscribe(l) {
604
809
  listeners.add(l);
605
- return () => listeners.delete(l);
810
+ return () => {
811
+ listeners.delete(l);
812
+ };
606
813
  }
607
814
  };
608
815
  }
816
+ async function captureCurrentViewSnapshot(i18next) {
817
+ const reg = globalThis.__verbumia_key_registry__;
818
+ if (!reg) return [];
819
+ const lng = i18next?.language;
820
+ const change = i18next?.changeLanguage;
821
+ if (typeof change === "function" && typeof lng === "string" && lng) {
822
+ reg.reset?.();
823
+ try {
824
+ await change(lng);
825
+ } catch {
826
+ }
827
+ await new Promise((r) => {
828
+ if (typeof requestAnimationFrame === "function") {
829
+ requestAnimationFrame(() => r());
830
+ } else {
831
+ setTimeout(() => r(), 16);
832
+ }
833
+ });
834
+ return reg.snapshot();
835
+ }
836
+ return reg.snapshot();
837
+ }
609
838
  function feedbackPlugin(options) {
610
839
  const store = makeStore();
611
840
  let client = null;
612
841
  function Outlet() {
613
- const isOpen = (0, import_react2.useSyncExternalStore)(
842
+ const state = (0, import_react2.useSyncExternalStore)(
614
843
  store.subscribe,
615
- store.isOpen,
616
- store.isOpen
844
+ store.getState,
845
+ store.getState
617
846
  );
618
- if (!isOpen || !client || typeof document === "undefined") return null;
847
+ if (!state.isOpen || !client || typeof document === "undefined") return null;
619
848
  const c = client;
849
+ const panelKeys = options.keys ?? state.snapshotKeys;
620
850
  return (0, import_react_dom.createPortal)(
621
851
  /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
622
852
  FeedbackPanel,
623
853
  {
624
854
  client: c,
625
- keys: options.keys,
855
+ keys: panelKeys,
626
856
  namespace: options.namespace,
857
+ tos: options.tos ?? "modal",
627
858
  onClose: () => {
628
- store.set(false);
859
+ store.setOpen(false);
629
860
  void c.flush();
630
861
  }
631
862
  }
@@ -637,24 +868,71 @@ function feedbackPlugin(options) {
637
868
  name: "@verbumia/feedback",
638
869
  setup(ctx) {
639
870
  const initialLanguage = options.language ?? ctx.i18n?.language ?? ctx.config.defaultLocale;
871
+ const tos = options.tos ?? "modal";
640
872
  client = new FeedbackClient({
641
873
  apiBase: options.apiBase ?? ctx.config.apiBase ?? "https://api.verbumia.dev",
642
874
  projectId: options.projectId ?? ctx.config.projectUuid,
643
875
  language: initialLanguage,
644
876
  endUserId: options.endUserId,
645
- fetchImpl: options.fetchImpl
877
+ fetchImpl: options.fetchImpl,
878
+ apiKey: options.apiKey,
879
+ autoAcceptTos: tos !== "skip"
646
880
  });
881
+ const clientRef = client;
882
+ let addonState = null;
883
+ const cta = options.cta ?? "auto";
884
+ const scope = options.scope ?? "current-view";
885
+ const ctxI18next = ctx.i18n?.i18next;
886
+ const refreshState = async () => {
887
+ try {
888
+ const next = await clientRef.getAddonState();
889
+ if (next !== null) addonState = next;
890
+ } catch {
891
+ }
892
+ };
893
+ if (options.apiKey) void refreshState();
647
894
  let langUnsub;
648
895
  if (typeof ctx.onLanguageChange === "function") {
649
- langUnsub = ctx.onLanguageChange((lng) => client?.setLanguage(lng));
896
+ langUnsub = ctx.onLanguageChange((lng) => {
897
+ clientRef.setLanguage(lng);
898
+ void refreshState();
899
+ });
650
900
  }
651
901
  const controller = {
652
- open: () => store.set(true),
902
+ open: async () => {
903
+ if (scope === "current-view") {
904
+ const snapshot = await captureCurrentViewSnapshot(ctxI18next);
905
+ store.setOpen(true, snapshot);
906
+ return;
907
+ }
908
+ store.setOpen(true);
909
+ },
653
910
  close: () => {
654
- store.set(false);
655
- void client?.flush();
911
+ store.setOpen(false);
912
+ void clientRef?.flush();
913
+ },
914
+ client: clientRef,
915
+ get tosVersion() {
916
+ return clientRef.tosVersion;
917
+ },
918
+ get hasAcceptedTos() {
919
+ return clientRef.hasAcceptedTos;
920
+ },
921
+ acceptTos: async () => {
922
+ await clientRef.acceptTos();
923
+ if (!options.apiKey) await refreshState();
656
924
  },
657
- client
925
+ get isActive() {
926
+ if (cta === "show") return true;
927
+ if (cta === "hide") return false;
928
+ return addonState ? addonState.isActive : null;
929
+ },
930
+ get enabledLanguages() {
931
+ return addonState ? addonState.enabledLanguages : null;
932
+ },
933
+ get sku() {
934
+ return addonState ? addonState.sku : null;
935
+ }
658
936
  };
659
937
  options.onReady?.(controller);
660
938
  if (options.controllerRef) options.controllerRef.current = controller;