@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
@@ -80,11 +80,61 @@ var FeedbackClient = class {
80
80
  get hasConsented() {
81
81
  return this.tokens !== null;
82
82
  }
83
+ /** Alias of `hasConsented` exposed under the 0.2.7 naming used by the
84
+ * `controller.hasAcceptedTos` getter (the SDK's built-in modal and a
85
+ * host's external ToS page share the same persisted token bundle, so
86
+ * both flip this boolean once a session is bootstrapped). */
87
+ get hasAcceptedTos() {
88
+ return this.tokens !== null;
89
+ }
83
90
  /** Server-minted sessionId / grouping_key (from the token bundle).
84
91
  * Available only after `acceptTos()`; never client-generated. */
85
92
  get sessionId() {
86
93
  return this.tokens?.grouping_key;
87
94
  }
95
+ /**
96
+ * 0.2.7 — `GET /v1/projects/{projectId}/feedback-addon/state`.
97
+ *
98
+ * Auth selection (matches backend's dual-acceptance after task 836's
99
+ * follow-up PR — see CONTRACT note in the response type):
100
+ * - If `cfg.apiKey` is set → `Authorization: ApiKey <key>` (so the
101
+ * plugin can fetch state at `setup()` time, before the user has
102
+ * accepted ToS).
103
+ * - Else if a user-session bundle exists → `Authorization: Bearer
104
+ * <access_token>` (the deferred-after-acceptTos path).
105
+ * - Else → throw `FeedbackError("addon state requires apiKey or
106
+ * acceptTos")`. The plugin's setup catches and surfaces a
107
+ * console.warn the first time, then re-attempts on the
108
+ * bundle-mint that follows `acceptTos()`.
109
+ *
110
+ * Best-effort: a transport error returns `null` instead of throwing,
111
+ * so the controller's `isActive` falls back to whatever was last
112
+ * known (or its initial value) without flipping the host's CTA into
113
+ * an inconsistent state on a transient blip.
114
+ */
115
+ async getAddonState() {
116
+ let auth;
117
+ if (this.cfg.apiKey) {
118
+ auth = `ApiKey ${this.cfg.apiKey}`;
119
+ } else if (this.tokens) {
120
+ auth = `Bearer ${this.tokens.access_token}`;
121
+ } else {
122
+ throw new FeedbackError(
123
+ "addon state requires apiKey or acceptTos"
124
+ );
125
+ }
126
+ const url = `${this.base()}/v1/projects/${this.cfg.projectId}/feedback-addon/state`;
127
+ try {
128
+ const res = await this.fetchImpl(url, {
129
+ method: "GET",
130
+ headers: { Authorization: auth }
131
+ });
132
+ if (!res.ok) return null;
133
+ return await res.json();
134
+ } catch {
135
+ return null;
136
+ }
137
+ }
88
138
  /** BCP-47 language the widget is rating strings in. */
89
139
  get language() {
90
140
  return this.cfg.language;
@@ -156,9 +206,18 @@ var FeedbackClient = class {
156
206
  }
157
207
  this.tokens = await res.json();
158
208
  }
159
- /** Authenticated fetch with a single transparent refresh-on-401 retry. */
209
+ /** Authenticated fetch with a single transparent refresh-on-401 retry.
210
+ * When `cfg.autoAcceptTos === false` (the plugin's `tos: "skip"` opt-in
211
+ * / 0.2.7) and no token has been minted yet, throws a `not consented`
212
+ * error instead of silently calling `acceptTos()` — the host promises
213
+ * to handle consent externally via `controller.acceptTos()`. */
160
214
  async authed(path, init, retry = true) {
161
- if (!this.tokens) await this.acceptTos();
215
+ if (!this.tokens) {
216
+ if (this.cfg.autoAcceptTos === false) {
217
+ throw new FeedbackError("not consented");
218
+ }
219
+ await this.acceptTos();
220
+ }
162
221
  const res = await this.fetchImpl(`${this.base()}${path}`, {
163
222
  ...init,
164
223
  headers: {
@@ -195,6 +254,31 @@ var FeedbackClient = class {
195
254
  suggest(payload) {
196
255
  this.enqueue({ kind: "suggestion", payload });
197
256
  }
257
+ /**
258
+ * 0.2.8 — `PATCH /v1/feedback/suggestions/{id}` (backend task 847,
259
+ * deploy `45190c8`). Used by the panel's edit-mode editor when the
260
+ * end-user already has a pending suggestion for a string (the
261
+ * `FeedbackString.my_suggestion.id` from a prior submit). Submits
262
+ * synchronously rather than going through the rating/suggestion
263
+ * batch queue — the host triggered an explicit edit, not a passive
264
+ * rating, so we want the round-trip to surface before the panel
265
+ * closes. Best-effort failures bubble; the caller catches and
266
+ * shows an error row in the panel.
267
+ *
268
+ * Wire body: `{ text: string }` (backend probe confirmed). NOT the
269
+ * legacy `{ suggested_text }` of the batched POST path.
270
+ */
271
+ async editSuggestion(id, text) {
272
+ if (!id) throw new FeedbackError("editSuggestion: id is required");
273
+ const trimmed = (text ?? "").trim();
274
+ if (!trimmed) throw new FeedbackError("editSuggestion: text is required");
275
+ const res = await this.authed(`/v1/feedback/suggestions/${encodeURIComponent(id)}`, {
276
+ method: "PATCH",
277
+ headers: { "Content-Type": "application/json" },
278
+ body: JSON.stringify({ text: trimmed })
279
+ });
280
+ if (!res.ok) throw await this.problem(res, "failed to update suggestion");
281
+ }
198
282
  enqueue(item) {
199
283
  this.queue.push(item);
200
284
  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/native/panel.tsx
295
415
  var import_jsx_runtime = require("react/jsx-runtime");
296
416
  var SafeAreaView;
@@ -314,11 +434,14 @@ var C = {
314
434
  emeraldSoft: "#34d399"
315
435
  };
316
436
  function FeedbackModal(props) {
317
- const { client, visible, keys, namespace, onClose } = props;
318
- const [consented, setConsented] = (0, import_react.useState)(client.hasConsented);
437
+ const { client, visible, keys, namespace, tos = "modal", onClose } = props;
438
+ const [consented, setConsented] = (0, import_react.useState)(
439
+ tos === "skip" ? true : client.hasConsented
440
+ );
319
441
  const [busy, setBusy] = (0, import_react.useState)(false);
320
442
  const [error, setError] = (0, import_react.useState)(null);
321
443
  const [strings, setStrings] = (0, import_react.useState)([]);
444
+ const [showSource, setShowSource] = (0, import_react.useState)(false);
322
445
  const loadStrings = (0, import_react.useCallback)(async () => {
323
446
  setBusy(true);
324
447
  setError(null);
@@ -416,14 +539,49 @@ function FeedbackModal(props) {
416
539
  // to its intrinsic height and the panel left a large empty
417
540
  // area below the last row (the second half of the
418
541
  // SeedSower repro).
419
- /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_react_native.ScrollView, { style: { flex: 1 }, children: !strings.length ? /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_react_native.Text, { style: { color: C.dim }, children: busy ? "Loading\u2026" : "No strings to review on this view." }) : strings.map((s) => /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
420
- StringRow,
421
- {
422
- s,
423
- client
424
- },
425
- `${s.namespace}:${s.key}`
426
- )) })
542
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_react_native.ScrollView, { style: { flex: 1 }, children: !strings.length ? /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_react_native.Text, { style: { color: C.dim }, children: busy ? "Loading\u2026" : "No strings to review on this view." }) : /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_jsx_runtime.Fragment, { children: [
543
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(
544
+ import_react_native.TouchableOpacity,
545
+ {
546
+ onPress: () => setShowSource((v) => !v),
547
+ accessibilityLabel: t(client.language, "showOriginal"),
548
+ style: {
549
+ alignSelf: "flex-end",
550
+ flexDirection: "row",
551
+ alignItems: "center",
552
+ marginBottom: 10,
553
+ paddingVertical: 4,
554
+ paddingHorizontal: 8,
555
+ borderWidth: 1,
556
+ borderColor: C.border,
557
+ borderRadius: 6
558
+ },
559
+ children: [
560
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
561
+ import_react_native.Text,
562
+ {
563
+ style: {
564
+ color: showSource ? C.emeraldSoft : C.dim,
565
+ fontSize: 12,
566
+ marginRight: 6
567
+ },
568
+ children: showSource ? "\u2611" : "\u2610"
569
+ }
570
+ ),
571
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_react_native.Text, { style: { color: C.dim, fontSize: 12 }, children: t(client.language, "showOriginal") })
572
+ ]
573
+ }
574
+ ),
575
+ strings.map((s) => /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
576
+ StringRow,
577
+ {
578
+ s,
579
+ client,
580
+ showSource
581
+ },
582
+ `${s.namespace}:${s.key}`
583
+ ))
584
+ ] }) })
427
585
  )
428
586
  ] })
429
587
  }
@@ -434,11 +592,42 @@ function FeedbackModal(props) {
434
592
  );
435
593
  }
436
594
  function StringRow(props) {
437
- const { s, client } = props;
595
+ const { s, client, showSource } = props;
438
596
  const [mine, setMine] = (0, import_react.useState)(s.my_rating);
439
597
  const [show, setShow] = (0, import_react.useState)(false);
440
- const [text, setText] = (0, import_react.useState)("");
598
+ const mySuggestionId = s.my_suggestion ? s.my_suggestion.id : null;
599
+ const [text, setText] = (0, import_react.useState)(s.my_suggestion?.text ?? "");
441
600
  const [sent, setSent] = (0, import_react.useState)(false);
601
+ const [editError, setEditError] = (0, import_react.useState)(null);
602
+ const submit = async () => {
603
+ const trimmed = text.trim();
604
+ if (!trimmed) return;
605
+ setEditError(null);
606
+ try {
607
+ if (mySuggestionId) {
608
+ await client.editSuggestion(mySuggestionId, trimmed);
609
+ } else {
610
+ client.suggest({
611
+ namespace: s.namespace,
612
+ key: s.key,
613
+ language: client.language,
614
+ translation_hash: s.translation_hash,
615
+ suggested_text: trimmed
616
+ });
617
+ }
618
+ setSent(true);
619
+ setShow(false);
620
+ } catch (e) {
621
+ setEditError(
622
+ e instanceof Error ? e.message : "Could not update the suggestion"
623
+ );
624
+ }
625
+ };
626
+ const showSourceRow = showSource && s.source_locale !== client.language;
627
+ const submitLabel = t(
628
+ client.language,
629
+ mySuggestionId ? "updateMySuggestion" : "submitSuggestion"
630
+ );
442
631
  return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(
443
632
  import_react_native.View,
444
633
  {
@@ -456,6 +645,19 @@ function StringRow(props) {
456
645
  " \xB7 ",
457
646
  s.key
458
647
  ] }),
648
+ showSourceRow ? /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
649
+ import_react_native.Text,
650
+ {
651
+ accessibilityLabel: "source",
652
+ style: {
653
+ color: C.dim,
654
+ fontSize: 12,
655
+ fontStyle: "italic",
656
+ marginTop: 4
657
+ },
658
+ children: s.source_text
659
+ }
660
+ ) : null,
459
661
  /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_react_native.Text, { style: { color: C.text, fontSize: 15, marginVertical: 6 }, children: s.value }),
460
662
  /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_react_native.View, { style: { flexDirection: "row" }, children: [
461
663
  [1, 2, 3, 4, 5].map((n) => /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
@@ -491,7 +693,7 @@ function StringRow(props) {
491
693
  {
492
694
  onPress: () => setShow(!show),
493
695
  style: { marginLeft: "auto" },
494
- children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_react_native.Text, { style: { color: C.emeraldSoft }, children: sent ? "Suggested \u2713" : "Suggest" })
696
+ children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_react_native.Text, { style: { color: C.emeraldSoft }, children: sent ? "Suggested \u2713" : submitLabel })
495
697
  }
496
698
  )
497
699
  ] }),
@@ -515,29 +717,18 @@ function StringRow(props) {
515
717
  }
516
718
  }
517
719
  ),
720
+ editError ? /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_react_native.Text, { style: { color: "#f87171", fontSize: 12, marginTop: 6 }, children: editError }) : null,
518
721
  /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
519
722
  import_react_native.TouchableOpacity,
520
723
  {
521
- onPress: () => {
522
- if (!text.trim()) return;
523
- client.suggest({
524
- namespace: s.namespace,
525
- key: s.key,
526
- language: client.language,
527
- translation_hash: s.translation_hash,
528
- suggested_text: text.trim()
529
- });
530
- setSent(true);
531
- setShow(false);
532
- setText("");
533
- },
724
+ onPress: () => void submit(),
534
725
  style: {
535
726
  marginTop: 6,
536
727
  backgroundColor: C.emerald,
537
728
  borderRadius: 6,
538
729
  padding: 10
539
730
  },
540
- children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_react_native.Text, { style: { color: "#03110c", fontWeight: "700", textAlign: "center" }, children: "Send suggestion" })
731
+ children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_react_native.Text, { style: { color: "#03110c", fontWeight: "700", textAlign: "center" }, children: submitLabel })
541
732
  }
542
733
  )
543
734
  ] }) : null
@@ -549,42 +740,73 @@ function StringRow(props) {
549
740
  // src/native/plugin.tsx
550
741
  var import_jsx_runtime2 = require("react/jsx-runtime");
551
742
  function makeStore() {
552
- let open = false;
743
+ let state = { isOpen: false, snapshotKeys: void 0 };
553
744
  const listeners = /* @__PURE__ */ new Set();
554
745
  return {
555
- isOpen: () => open,
556
- set(v) {
557
- if (open !== v) {
558
- open = v;
559
- listeners.forEach((l) => l());
746
+ getState: () => state,
747
+ setOpen(open, snapshotKeys) {
748
+ const next = {
749
+ isOpen: open,
750
+ snapshotKeys: open ? snapshotKeys : void 0
751
+ };
752
+ if (state.isOpen === next.isOpen && state.snapshotKeys === next.snapshotKeys) {
753
+ return;
560
754
  }
755
+ state = next;
756
+ listeners.forEach((l) => l());
561
757
  },
562
758
  subscribe(l) {
563
759
  listeners.add(l);
564
- return () => listeners.delete(l);
760
+ return () => {
761
+ listeners.delete(l);
762
+ };
565
763
  }
566
764
  };
567
765
  }
766
+ async function captureCurrentViewSnapshot(i18next) {
767
+ const reg = globalThis.__verbumia_key_registry__;
768
+ if (!reg) return [];
769
+ const lng = i18next?.language;
770
+ const change = i18next?.changeLanguage;
771
+ if (typeof change === "function" && typeof lng === "string" && lng) {
772
+ reg.reset?.();
773
+ try {
774
+ await change(lng);
775
+ } catch {
776
+ }
777
+ await new Promise((r) => {
778
+ if (typeof requestAnimationFrame === "function") {
779
+ requestAnimationFrame(() => r());
780
+ } else {
781
+ setTimeout(() => r(), 16);
782
+ }
783
+ });
784
+ return reg.snapshot();
785
+ }
786
+ return reg.snapshot();
787
+ }
568
788
  function feedbackPlugin(options) {
569
789
  const store = makeStore();
570
790
  let client = null;
571
791
  function Outlet() {
572
- const isOpen = (0, import_react2.useSyncExternalStore)(
792
+ const state = (0, import_react2.useSyncExternalStore)(
573
793
  store.subscribe,
574
- store.isOpen,
575
- store.isOpen
794
+ store.getState,
795
+ store.getState
576
796
  );
577
797
  if (!client) return null;
578
798
  const c = client;
799
+ const panelKeys = options.keys ?? state.snapshotKeys;
579
800
  return /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
580
801
  FeedbackModal,
581
802
  {
582
803
  client: c,
583
- visible: isOpen,
584
- keys: options.keys,
804
+ visible: state.isOpen,
805
+ keys: panelKeys,
585
806
  namespace: options.namespace,
807
+ tos: options.tos ?? "modal",
586
808
  onClose: () => {
587
- store.set(false);
809
+ store.setOpen(false);
588
810
  void c.flush();
589
811
  }
590
812
  }
@@ -594,24 +816,71 @@ function feedbackPlugin(options) {
594
816
  name: "@verbumia/feedback",
595
817
  setup(ctx) {
596
818
  const initialLanguage = options.language ?? ctx.i18n?.language ?? ctx.config.defaultLocale;
819
+ const tos = options.tos ?? "modal";
597
820
  client = new FeedbackClient({
598
821
  apiBase: options.apiBase ?? ctx.config.apiBase ?? "https://api.verbumia.dev",
599
822
  projectId: options.projectId ?? ctx.config.projectUuid,
600
823
  language: initialLanguage,
601
824
  endUserId: options.endUserId,
602
- fetchImpl: options.fetchImpl
825
+ fetchImpl: options.fetchImpl,
826
+ apiKey: options.apiKey,
827
+ autoAcceptTos: tos !== "skip"
603
828
  });
829
+ const clientRef = client;
830
+ let addonState = null;
831
+ const cta = options.cta ?? "auto";
832
+ const scope = options.scope ?? "current-view";
833
+ const ctxI18next = ctx.i18n?.i18next;
834
+ const refreshState = async () => {
835
+ try {
836
+ const next = await clientRef.getAddonState();
837
+ if (next !== null) addonState = next;
838
+ } catch {
839
+ }
840
+ };
841
+ if (options.apiKey) void refreshState();
604
842
  let langUnsub;
605
843
  if (typeof ctx.onLanguageChange === "function") {
606
- langUnsub = ctx.onLanguageChange((lng) => client?.setLanguage(lng));
844
+ langUnsub = ctx.onLanguageChange((lng) => {
845
+ clientRef.setLanguage(lng);
846
+ void refreshState();
847
+ });
607
848
  }
608
849
  const controller = {
609
- open: () => store.set(true),
850
+ open: async () => {
851
+ if (scope === "current-view") {
852
+ const snapshot = await captureCurrentViewSnapshot(ctxI18next);
853
+ store.setOpen(true, snapshot);
854
+ return;
855
+ }
856
+ store.setOpen(true);
857
+ },
610
858
  close: () => {
611
- store.set(false);
612
- void client?.flush();
859
+ store.setOpen(false);
860
+ void clientRef?.flush();
861
+ },
862
+ client: clientRef,
863
+ get tosVersion() {
864
+ return clientRef.tosVersion;
865
+ },
866
+ get hasAcceptedTos() {
867
+ return clientRef.hasAcceptedTos;
868
+ },
869
+ acceptTos: async () => {
870
+ await clientRef.acceptTos();
871
+ if (!options.apiKey) await refreshState();
613
872
  },
614
- client
873
+ get isActive() {
874
+ if (cta === "show") return true;
875
+ if (cta === "hide") return false;
876
+ return addonState ? addonState.isActive : null;
877
+ },
878
+ get enabledLanguages() {
879
+ return addonState ? addonState.enabledLanguages : null;
880
+ },
881
+ get sku() {
882
+ return addonState ? addonState.sku : null;
883
+ }
615
884
  };
616
885
  options.onReady?.(controller);
617
886
  if (options.controllerRef) options.controllerRef.current = controller;