@coventia/softphone 0.1.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/dist/{call-BTYlT2SU.d.cts → call-VKq1I8yP.d.cts} +158 -5
  2. package/dist/{call-BTYlT2SU.d.ts → call-VKq1I8yP.d.ts} +158 -5
  3. package/dist/{chunk-N2N2EQQJ.js → chunk-7R6LOOAI.js} +4 -2
  4. package/dist/chunk-7R6LOOAI.js.map +1 -0
  5. package/dist/{chunk-5BGEHMPP.js → chunk-EYEDPD7Y.js} +146 -20
  6. package/dist/chunk-EYEDPD7Y.js.map +1 -0
  7. package/dist/{chunk-3V2L6UTK.js → chunk-LMJXX4YM.js} +17 -3
  8. package/dist/chunk-LMJXX4YM.js.map +1 -0
  9. package/dist/element/index.d.ts +162 -7
  10. package/dist/element/index.js +866 -421
  11. package/dist/element/index.js.map +1 -1
  12. package/dist/{engine-D9nbeY08.d.cts → engine-BlQBo2Ys.d.cts} +1 -1
  13. package/dist/{engine-Doa6Brkm.d.ts → engine-C0BOkIcW.d.ts} +1 -1
  14. package/dist/index.cjs +586 -28
  15. package/dist/index.cjs.map +1 -1
  16. package/dist/index.d.cts +5 -5
  17. package/dist/index.d.ts +5 -5
  18. package/dist/index.js +335 -8
  19. package/dist/index.js.map +1 -1
  20. package/dist/react/index.cjs +15 -0
  21. package/dist/react/index.cjs.map +1 -1
  22. package/dist/react/index.d.cts +14 -3
  23. package/dist/react/index.d.ts +14 -3
  24. package/dist/react/index.js +2 -2
  25. package/dist/{softphone-DhuhcKCy.d.cts → softphone-Cyq3hiIM.d.ts} +17 -2
  26. package/dist/{softphone-DHmhotbf.d.ts → softphone-DlGV12Xp.d.cts} +17 -2
  27. package/dist/softphone.css +477 -35
  28. package/dist/{sources-N4BOEDRL.js → sources-IRKNQPWW.js} +108 -5
  29. package/dist/sources-IRKNQPWW.js.map +1 -0
  30. package/dist/testing/index.cjs +41 -5
  31. package/dist/testing/index.cjs.map +1 -1
  32. package/dist/testing/index.d.cts +8 -2
  33. package/dist/testing/index.d.ts +8 -2
  34. package/dist/testing/index.js +41 -5
  35. package/dist/testing/index.js.map +1 -1
  36. package/dist/ui/index.cjs +846 -410
  37. package/dist/ui/index.cjs.map +1 -1
  38. package/dist/ui/index.d.cts +17 -6
  39. package/dist/ui/index.d.ts +17 -6
  40. package/dist/ui/index.js +878 -456
  41. package/dist/ui/index.js.map +1 -1
  42. package/dist/zadarma/index.cjs +144 -18
  43. package/dist/zadarma/index.cjs.map +1 -1
  44. package/dist/zadarma/index.d.cts +2 -2
  45. package/dist/zadarma/index.d.ts +2 -2
  46. package/dist/zadarma/index.js +1 -1
  47. package/dist/zadarma-engine-L5DEFXUS.js +5 -0
  48. package/dist/{zadarma-engine-DN2SZSQ3.js.map → zadarma-engine-L5DEFXUS.js.map} +1 -1
  49. package/package.json +1 -1
  50. package/dist/chunk-3V2L6UTK.js.map +0 -1
  51. package/dist/chunk-5BGEHMPP.js.map +0 -1
  52. package/dist/chunk-N2N2EQQJ.js.map +0 -1
  53. package/dist/sources-N4BOEDRL.js.map +0 -1
  54. package/dist/zadarma-engine-DN2SZSQ3.js +0 -5
package/dist/ui/index.cjs CHANGED
@@ -13418,6 +13418,20 @@ function useActiveCall() {
13418
13418
  React.useCallback(() => phone.activeCall, [phone])
13419
13419
  );
13420
13420
  }
13421
+ function usePresence(active) {
13422
+ const phone = useSoftphone();
13423
+ return React.useSyncExternalStore(
13424
+ React.useCallback(
13425
+ (cb) => {
13426
+ if (!active) return () => {
13427
+ };
13428
+ return phone.on("presenceChanged" /* PresenceChanged */, () => cb());
13429
+ },
13430
+ [phone, active]
13431
+ ),
13432
+ React.useCallback(() => phone.presence, [phone])
13433
+ );
13434
+ }
13421
13435
  var CALL_EVENTS = [
13422
13436
  "stateChanged" /* StateChanged */,
13423
13437
  "muted" /* Muted */,
@@ -13707,21 +13721,263 @@ function caretAfterDigits(formatted, digitsBefore) {
13707
13721
  function digitsBeforeCaret(text, caret) {
13708
13722
  return (text.slice(0, caret).match(/\d/g) ?? []).length;
13709
13723
  }
13710
- var KEYS = [
13711
- ["1", "", true],
13712
- ["2", "ABC", true],
13713
- ["3", "DEF", true],
13714
- ["4", "GHI", true],
13715
- ["5", "JKL", true],
13716
- ["6", "MNO", true],
13717
- ["7", "PQRS", true],
13718
- ["8", "TUV", true],
13719
- ["9", "WXYZ", true],
13720
- ["*", "", false],
13721
- ["0", "+", true],
13722
- ["#", "", false]
13724
+
13725
+ // src/ui/dialing/keys.ts
13726
+ var KEYPAD_KEYS = [
13727
+ ["1", ""],
13728
+ ["2", "ABC"],
13729
+ ["3", "DEF"],
13730
+ ["4", "GHI"],
13731
+ ["5", "JKL"],
13732
+ ["6", "MNO"],
13733
+ ["7", "PQRS"],
13734
+ ["8", "TUV"],
13735
+ ["9", "WXYZ"],
13736
+ ["*", ""],
13737
+ ["0", "+"],
13738
+ ["#", ""]
13723
13739
  ];
13724
- function Dialpad({ outcome = null, onIntent } = {}) {
13740
+
13741
+ // src/ui/audio/audio.ts
13742
+ var TONE_HZ = 425;
13743
+ function createRemoteAudio() {
13744
+ const el = document.createElement("audio");
13745
+ el.autoplay = true;
13746
+ el.style.display = "none";
13747
+ document.body.appendChild(el);
13748
+ return {
13749
+ attach(stream) {
13750
+ el.srcObject = stream;
13751
+ void el.play().catch(() => {
13752
+ });
13753
+ },
13754
+ detach() {
13755
+ el.srcObject = null;
13756
+ },
13757
+ destroy() {
13758
+ el.srcObject = null;
13759
+ el.remove();
13760
+ },
13761
+ async setOutputDevice(deviceId) {
13762
+ if (typeof el.setSinkId !== "function") return;
13763
+ try {
13764
+ await el.setSinkId(deviceId ?? "");
13765
+ } catch {
13766
+ }
13767
+ }
13768
+ };
13769
+ }
13770
+ var shared = null;
13771
+ function sharedContext() {
13772
+ if (shared) return shared;
13773
+ const Ctor = typeof AudioContext !== "undefined" ? AudioContext : globalThis.webkitAudioContext;
13774
+ if (!Ctor) return null;
13775
+ shared = new Ctor();
13776
+ return shared;
13777
+ }
13778
+ function primeAudio() {
13779
+ const ctx = sharedContext();
13780
+ if (ctx && ctx.state === "suspended") void ctx.resume?.().catch(() => {
13781
+ });
13782
+ }
13783
+ function playRingtone() {
13784
+ const ctx = sharedContext();
13785
+ if (!ctx) return { stop() {
13786
+ } };
13787
+ const gain = ctx.createGain();
13788
+ gain.gain.value = 0;
13789
+ gain.connect(ctx.destination);
13790
+ const osc = ctx.createOscillator();
13791
+ osc.type = "sine";
13792
+ osc.frequency.value = TONE_HZ;
13793
+ osc.connect(gain);
13794
+ osc.start();
13795
+ const LEVEL = 0.8;
13796
+ const RAMP = 0.02;
13797
+ const burst = (at, dur) => {
13798
+ gain.gain.setValueAtTime(0, at);
13799
+ gain.gain.linearRampToValueAtTime(LEVEL, at + RAMP);
13800
+ gain.gain.setValueAtTime(LEVEL, at + dur - RAMP);
13801
+ gain.gain.linearRampToValueAtTime(0, at + dur);
13802
+ };
13803
+ const scheduleCycle = (at) => {
13804
+ burst(at, 0.4);
13805
+ burst(at + 0.6, 0.4);
13806
+ return 3;
13807
+ };
13808
+ const LOOKAHEAD = 0.5;
13809
+ const TICK_MS = 150;
13810
+ const LEAD = 0.08;
13811
+ let stopped = false;
13812
+ let timer = null;
13813
+ let nextAt = 0;
13814
+ const pump = () => {
13815
+ while (nextAt < ctx.currentTime + LOOKAHEAD) nextAt += scheduleCycle(nextAt);
13816
+ };
13817
+ void Promise.resolve(ctx.resume?.()).catch(() => {
13818
+ }).finally(() => {
13819
+ if (stopped) return;
13820
+ nextAt = ctx.currentTime + LEAD;
13821
+ pump();
13822
+ timer = setInterval(pump, TICK_MS);
13823
+ });
13824
+ return {
13825
+ stop() {
13826
+ stopped = true;
13827
+ if (timer) clearInterval(timer);
13828
+ try {
13829
+ osc.stop();
13830
+ } catch {
13831
+ }
13832
+ try {
13833
+ gain.disconnect();
13834
+ } catch {
13835
+ }
13836
+ }
13837
+ };
13838
+ }
13839
+ function playCallEndedTone() {
13840
+ const ctx = sharedContext();
13841
+ if (!ctx) return;
13842
+ const gain = ctx.createGain();
13843
+ gain.gain.value = 0;
13844
+ gain.connect(ctx.destination);
13845
+ const osc = ctx.createOscillator();
13846
+ osc.type = "sine";
13847
+ osc.frequency.value = TONE_HZ;
13848
+ osc.connect(gain);
13849
+ osc.start();
13850
+ const LEVEL = 0.55;
13851
+ const RAMP = 8e-3;
13852
+ const BEEP = 0.25;
13853
+ void Promise.resolve(ctx.resume?.()).catch(() => {
13854
+ }).finally(() => {
13855
+ const at = ctx.currentTime + 0.02;
13856
+ gain.gain.setValueAtTime(0, at);
13857
+ gain.gain.linearRampToValueAtTime(LEVEL, at + RAMP);
13858
+ gain.gain.setValueAtTime(LEVEL, at + BEEP - RAMP);
13859
+ gain.gain.linearRampToValueAtTime(0, at + BEEP);
13860
+ const totalMs = (at + BEEP - ctx.currentTime) * 1e3;
13861
+ setTimeout(() => {
13862
+ try {
13863
+ osc.stop();
13864
+ } catch {
13865
+ }
13866
+ try {
13867
+ gain.disconnect();
13868
+ } catch {
13869
+ }
13870
+ }, totalMs);
13871
+ });
13872
+ }
13873
+ var DTMF_ROWS_HZ = [697, 770, 852, 941];
13874
+ var DTMF_COLS_HZ = [1209, 1336, 1477];
13875
+ var DTMF_LAYOUT = ["123", "456", "789", "*0#"];
13876
+ function dtmfFrequencies(key) {
13877
+ if (key.length !== 1) return null;
13878
+ for (let row = 0; row < DTMF_LAYOUT.length; row++) {
13879
+ const col = DTMF_LAYOUT[row].indexOf(key);
13880
+ if (col !== -1) return [DTMF_ROWS_HZ[row], DTMF_COLS_HZ[col]];
13881
+ }
13882
+ return null;
13883
+ }
13884
+ function playKeyTone(key) {
13885
+ const hz = dtmfFrequencies(key);
13886
+ if (!hz) return;
13887
+ const ctx = sharedContext();
13888
+ if (!ctx) return;
13889
+ const LEVEL = 0.2;
13890
+ const RAMP = 6e-3;
13891
+ const DURATION = 0.12;
13892
+ const LEAD = 0.01;
13893
+ const gain = ctx.createGain();
13894
+ gain.gain.value = 0;
13895
+ gain.connect(ctx.destination);
13896
+ const oscillators = hz.map((freq) => {
13897
+ const osc = ctx.createOscillator();
13898
+ osc.type = "sine";
13899
+ osc.frequency.value = freq;
13900
+ osc.connect(gain);
13901
+ osc.start();
13902
+ return osc;
13903
+ });
13904
+ const fire = () => {
13905
+ const at = ctx.currentTime + LEAD;
13906
+ gain.gain.setValueAtTime(0, at);
13907
+ gain.gain.linearRampToValueAtTime(LEVEL, at + RAMP);
13908
+ gain.gain.setValueAtTime(LEVEL, at + DURATION - RAMP);
13909
+ gain.gain.linearRampToValueAtTime(0, at + DURATION);
13910
+ for (const osc of oscillators) osc.stop(at + DURATION);
13911
+ oscillators[0].onended = () => gain.disconnect();
13912
+ };
13913
+ if (ctx.state === "running") fire();
13914
+ else void Promise.resolve(ctx.resume?.()).catch(() => {
13915
+ }).finally(fire);
13916
+ }
13917
+ var ringbackUrl = null;
13918
+ function ringbackSource() {
13919
+ if (ringbackUrl) return ringbackUrl;
13920
+ const sampleRate = 48e3;
13921
+ const channels = 2;
13922
+ const bytesPerSample = 2;
13923
+ const amp = 0.5;
13924
+ const toneSec = 1.5;
13925
+ const silenceSec = 3;
13926
+ const totalFrames = Math.round((toneSec + silenceSec) * sampleRate);
13927
+ const toneFrames = Math.round(toneSec * sampleRate);
13928
+ const ramp = Math.round(0.02 * sampleRate);
13929
+ const blockAlign = channels * bytesPerSample;
13930
+ const dataSize = totalFrames * blockAlign;
13931
+ const buffer = new ArrayBuffer(44 + dataSize);
13932
+ const view = new DataView(buffer);
13933
+ const str = (off, s) => {
13934
+ for (let i = 0; i < s.length; i++) view.setUint8(off + i, s.charCodeAt(i));
13935
+ };
13936
+ str(0, "RIFF");
13937
+ view.setUint32(4, 36 + dataSize, true);
13938
+ str(8, "WAVE");
13939
+ str(12, "fmt ");
13940
+ view.setUint32(16, 16, true);
13941
+ view.setUint16(20, 1, true);
13942
+ view.setUint16(22, channels, true);
13943
+ view.setUint32(24, sampleRate, true);
13944
+ view.setUint32(28, sampleRate * blockAlign, true);
13945
+ view.setUint16(32, blockAlign, true);
13946
+ view.setUint16(34, bytesPerSample * 8, true);
13947
+ str(36, "data");
13948
+ view.setUint32(40, dataSize, true);
13949
+ for (let i = 0; i < totalFrames; i++) {
13950
+ let sample = 0;
13951
+ if (i < toneFrames) {
13952
+ let env = 1;
13953
+ if (i < ramp) env = i / ramp;
13954
+ else if (i > toneFrames - ramp) env = Math.max(0, (toneFrames - i) / ramp);
13955
+ sample = Math.sin(2 * Math.PI * TONE_HZ * (i / sampleRate)) * amp * env;
13956
+ }
13957
+ const s16 = Math.round(sample * 32767);
13958
+ const off = 44 + i * blockAlign;
13959
+ for (let c = 0; c < channels; c++) view.setInt16(off + c * bytesPerSample, s16, true);
13960
+ }
13961
+ ringbackUrl = URL.createObjectURL(new Blob([buffer], { type: "audio/wav" }));
13962
+ return ringbackUrl;
13963
+ }
13964
+ function playRingback() {
13965
+ const el = document.createElement("audio");
13966
+ el.src = ringbackSource();
13967
+ el.loop = true;
13968
+ el.style.display = "none";
13969
+ document.body.appendChild(el);
13970
+ void el.play().catch(() => {
13971
+ });
13972
+ return {
13973
+ stop() {
13974
+ el.pause();
13975
+ el.remove();
13976
+ }
13977
+ };
13978
+ }
13979
+ var NOT_DIALABLE = /* @__PURE__ */ new Set(["*", "#"]);
13980
+ function Dialpad({ outcome = null, onIntent, keyTones = true } = {}) {
13725
13981
  const softphone = useSoftphone();
13726
13982
  const state = useSoftphoneState();
13727
13983
  const [value, setValue] = React.useState("");
@@ -13742,6 +13998,7 @@ function Dialpad({ outcome = null, onIntent } = {}) {
13742
13998
  const append = (key) => {
13743
13999
  onIntent?.();
13744
14000
  setValue((v) => v + key);
14001
+ if (keyTones) playKeyTone(key);
13745
14002
  };
13746
14003
  const backspace = () => {
13747
14004
  onIntent?.();
@@ -13786,22 +14043,25 @@ function Dialpad({ outcome = null, onIntent } = {}) {
13786
14043
  )
13787
14044
  ] }),
13788
14045
  /* @__PURE__ */ jsxRuntime.jsx("p", { className: "cv-dial-outcome", role: "status", part: "error", "data-shown": outcome !== null, children: outcome?.text ?? "" }),
13789
- /* @__PURE__ */ jsxRuntime.jsx("div", { className: "cv-keypad", children: KEYS.map(([key, letters, dialable]) => /* @__PURE__ */ jsxRuntime.jsxs(
13790
- "button",
13791
- {
13792
- type: "button",
13793
- className: "cv-key",
13794
- "data-dialable": dialable,
13795
- onClick: () => dialable && append(key),
13796
- "aria-disabled": !dialable,
13797
- title: dialable ? void 0 : "Solo disponible durante una llamada",
13798
- children: [
13799
- /* @__PURE__ */ jsxRuntime.jsx("b", { children: key }),
13800
- letters ? /* @__PURE__ */ jsxRuntime.jsx("small", { children: letters }) : null
13801
- ]
13802
- },
13803
- key
13804
- )) }),
14046
+ /* @__PURE__ */ jsxRuntime.jsx("div", { className: "cv-keypad", children: KEYPAD_KEYS.map(([key, letters]) => {
14047
+ const dialable = !NOT_DIALABLE.has(key);
14048
+ return /* @__PURE__ */ jsxRuntime.jsxs(
14049
+ "button",
14050
+ {
14051
+ type: "button",
14052
+ className: "cv-key",
14053
+ "data-dialable": dialable,
14054
+ onClick: () => dialable && append(key),
14055
+ "aria-disabled": !dialable,
14056
+ title: dialable ? void 0 : "Solo disponible durante una llamada",
14057
+ children: [
14058
+ /* @__PURE__ */ jsxRuntime.jsx("b", { children: key }),
14059
+ letters ? /* @__PURE__ */ jsxRuntime.jsx("small", { children: letters }) : null
14060
+ ]
14061
+ },
14062
+ key
14063
+ );
14064
+ }) }),
13805
14065
  /* @__PURE__ */ jsxRuntime.jsx("div", { className: "cv-dial-foot", children: /* @__PURE__ */ jsxRuntime.jsx(
13806
14066
  "button",
13807
14067
  {
@@ -13836,31 +14096,150 @@ function elapsedSeconds(since, now) {
13836
14096
  if (!since) return 0;
13837
14097
  return Math.floor((now - since.getTime()) / 1e3);
13838
14098
  }
13839
- function useTicker(enabled, intervalMs = 1e3) {
13840
- const [now, setNow] = React.useState(() => Date.now());
13841
- React.useEffect(() => {
13842
- if (!enabled) return;
13843
- setNow(Date.now());
13844
- const id = setInterval(() => setNow(Date.now()), intervalMs);
13845
- return () => clearInterval(id);
13846
- }, [enabled, intervalMs]);
13847
- return now;
13848
- }
13849
- function deviceLabel(device, index, kind) {
13850
- if (device.label) return device.label;
13851
- return kind === "input" ? `Micr\xF3fono ${index + 1}` : `Altavoz ${index + 1}`;
14099
+ function formatAge(totalSeconds) {
14100
+ const s = Math.max(0, Math.floor(totalSeconds));
14101
+ if (s < 60) return "justo ahora";
14102
+ const mm = Math.floor(s / 60);
14103
+ if (mm < 60) return `hace ${mm} min`;
14104
+ const hh = Math.floor(mm / 60);
14105
+ return `hace ${hh} h`;
13852
14106
  }
13853
- var DEFAULT_OPTION = "";
13854
- function DevicePicker({
13855
- icon: Icon3,
13856
- label,
13857
- kind,
13858
- options,
13859
- value,
13860
- onChange,
13861
- emptyText
13862
- }) {
13863
- return /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "cv-settings-row", children: [
14107
+
14108
+ // src/ui/lib/error-text.ts
14109
+ var ERROR_TEXT = {
14110
+ // ── Engine lifecycle ────────────────────────────────────────────────────────
14111
+ // Integrator mistakes rather than user-facing failures, but they must still read as something
14112
+ // rather than as a blank panel if one ever reaches the UI.
14113
+ "engine/duplicate-instance": {
14114
+ title: "Tel\xE9fono duplicado",
14115
+ body: "Hay otro tel\xE9fono abierto en esta p\xE1gina. Cierra uno y vuelve a cargar."
14116
+ },
14117
+ "engine/not-started": { title: "El tel\xE9fono a\xFAn no est\xE1 listo" },
14118
+ "engine/destroyed": {
14119
+ title: "Tel\xE9fono cerrado",
14120
+ body: "Vuelve a cargar la p\xE1gina para usarlo de nuevo."
14121
+ },
14122
+ // ── Auth ────────────────────────────────────────────────────────────────────
14123
+ // Never "token": the user has no idea what one is. What they can act on is the session.
14124
+ "auth/token-fetch-failed": {
14125
+ title: "Sin conexi\xF3n con el servidor",
14126
+ body: "No se ha podido activar el tel\xE9fono. Reint\xE9ntalo en unos segundos."
14127
+ },
14128
+ "auth/token-rejected": {
14129
+ title: "Sesi\xF3n caducada",
14130
+ body: "Vuelve a iniciar sesi\xF3n para poder llamar."
14131
+ },
14132
+ // ── Media ───────────────────────────────────────────────────────────────────
14133
+ // Verbatim from MicPermissionGate, which already says these well and says them first.
14134
+ //
14135
+ // NEVER ACTUALLY SHOWN via this table — these four codes (through `media/playback-blocked`, below)
14136
+ // are never raised as a `SoftphoneError` at all. See the fuller comment on each in `core/errors.ts`
14137
+ // for why. Copy kept here anyway so the catalogue and this file stay the same shape as every other
14138
+ // code — `error-text.test.ts` would fail the build the moment they drifted.
14139
+ "media/permission-denied": {
14140
+ title: "Micr\xF3fono bloqueado",
14141
+ body: "Perm\xEDtelo en tu navegador para poder llamar."
14142
+ },
14143
+ "media/no-input-device": {
14144
+ title: "Sin micr\xF3fono",
14145
+ body: "Conecta un micr\xF3fono y vuelve a intentarlo."
14146
+ },
14147
+ "media/webrtc-unsupported": {
14148
+ title: "Llamadas no disponibles",
14149
+ body: "Este navegador no admite llamadas. Prueba con Chrome, Edge o Firefox."
14150
+ },
14151
+ "media/playback-blocked": { title: "Toca la pantalla para activar el sonido" },
14152
+ // ── Network / signalling ────────────────────────────────────────────────────
14153
+ // "Centralita" rather than "SIP" or "registro": the user knows what a phone system is.
14154
+ "net/registration-failed": {
14155
+ title: "No se pudo conectar",
14156
+ body: "No hemos podido conectar con la centralita. Reintenta en unos segundos."
14157
+ },
14158
+ "net/transport-failed": {
14159
+ title: "Conexi\xF3n perdida",
14160
+ body: "Se ha perdido la conexi\xF3n con la centralita."
14161
+ },
14162
+ // NEVER ACTUALLY SHOWN either — CallScreen's own "Reconectando…" already covers this, off the
14163
+ // call's `reconnecting` state rather than this channel. See the comment in `core/errors.ts`.
14164
+ "net/ice-failed": { title: "Recuperando el audio\u2026" },
14165
+ // ── Call ────────────────────────────────────────────────────────────────────
14166
+ /*
14167
+ THE VOICE RULE FOR THIS BLOCK: say what HAPPENED, not what it is called.
14168
+
14169
+ These were status labels — "Llamada perdida", "Llamada rechazada", "Sin respuesta". Each is a
14170
+ name for a category rather than an account of an event, which is why they read as a database enum
14171
+ someone forgot to translate into human. Worse, a label hides the ACTOR: "Llamada rechazada" never
14172
+ says who rejected what, so the user reading it after declining a call and the user reading it
14173
+ after being declined see the same three words.
14174
+
14175
+ So each one is a short sentence about what the other person, or the phone, did — the form
14176
+ `call/busy` already had, and the reason it was the only one here that read clearly.
14177
+
14178
+ Impersonal third-person plural ("contestaron", "colgaron") throughout: the user does not know who
14179
+ is at the other end, and it is what Spanish uses when the subject is unknown. Mixing it with the
14180
+ singular across neighbouring lines reads as carelessness.
14181
+
14182
+ LENGTH IS A HARD CONSTRAINT, not a preference. `.cv-dial-outcome` is one line at a fixed
14183
+ `1.15rem`, and the keypad below it is `flex: 1` — a title that wraps resizes every key and shifts
14184
+ the grid under a finger already reaching for it. `error-text.test.ts` caps this at 48 characters.
14185
+ */
14186
+ "call/not-answerable": { title: "Esa llamada ya ha terminado" },
14187
+ /*
14188
+ NOT "Comunicando", which is what this said and what sent us looking. SIP 486 is both "the line is
14189
+ engaged" AND what almost every endpoint sends when a person presses decline — including our own
14190
+ `reject()`. One code, two events, no way to tell them apart, so the copy has to hold both rather
14191
+ than assert the less likely one. `call/rejected` can be definite: an endpoint sending 603 Decline
14192
+ means exactly that.
14193
+ */
14194
+ "call/busy": { title: "No contestaron o la l\xEDnea estaba ocupada" },
14195
+ "call/no-answer": { title: "No contestaron" },
14196
+ "call/rejected": { title: "No aceptaron tu llamada" },
14197
+ /*
14198
+ The legacy CRM said "La persona que llamaba colgó antes de que pudieras contestar." — the right
14199
+ fact, at three times the width of the line that has to hold it. This keeps the fact and drops the
14200
+ words: the user needs to know the call was FOR them and that nobody is waiting on the line.
14201
+ "Llamada perdida" alone tested badly for exactly the label reason above — it names the category
14202
+ without saying anyone rang.
14203
+ */
14204
+ "call/missed": { title: "Colgaron antes de que pudieras contestar" },
14205
+ // "No se pudo completar la llamada" was our own "something went wrong": true of every failure and
14206
+ // useful for none. The number is what the user can actually act on.
14207
+ "call/failed": { title: "No se pudo conectar con ese n\xFAmero" },
14208
+ // The load-bearing half is the SECOND sentence: the original call is still up, and a user who
14209
+ // thinks they dropped it will hang up on a client to "fix" it.
14210
+ "call/transfer-failed": { title: "No se pudo transferir. Sigues en la llamada" },
14211
+ "call/unsupported": { title: "Esta funci\xF3n no est\xE1 disponible" },
14212
+ // ── Provider ────────────────────────────────────────────────────────────────
14213
+ "provider/provision-failed": {
14214
+ title: "No se pudo activar el tel\xE9fono",
14215
+ body: "Si el problema contin\xFAa, contacta con soporte."
14216
+ }
14217
+ };
14218
+ function useTicker(enabled, intervalMs = 1e3) {
14219
+ const [now, setNow] = React.useState(() => Date.now());
14220
+ React.useEffect(() => {
14221
+ if (!enabled) return;
14222
+ setNow(Date.now());
14223
+ const id = setInterval(() => setNow(Date.now()), intervalMs);
14224
+ return () => clearInterval(id);
14225
+ }, [enabled, intervalMs]);
14226
+ return now;
14227
+ }
14228
+ function deviceLabel(device, index, kind) {
14229
+ if (device.label) return device.label;
14230
+ return kind === "input" ? `Micr\xF3fono ${index + 1}` : `Altavoz ${index + 1}`;
14231
+ }
14232
+ var DEFAULT_OPTION = "";
14233
+ function DevicePicker({
14234
+ icon: Icon3,
14235
+ label,
14236
+ kind,
14237
+ options,
14238
+ value,
14239
+ onChange,
14240
+ emptyText
14241
+ }) {
14242
+ return /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "cv-settings-row", children: [
13864
14243
  /* @__PURE__ */ jsxRuntime.jsx("span", { className: "cv-settings-row-icon", "aria-hidden": true, children: /* @__PURE__ */ jsxRuntime.jsx(Icon3, { size: 16 }) }),
13865
14244
  /* @__PURE__ */ jsxRuntime.jsxs("span", { className: "cv-settings-row-text", children: [
13866
14245
  /* @__PURE__ */ jsxRuntime.jsx("span", { className: "cv-settings-row-label", id: `cv-settings-label-${kind}`, children: label }),
@@ -13922,7 +14301,9 @@ function Control({
13922
14301
  label,
13923
14302
  pressed = false,
13924
14303
  tone,
13925
- onClick
14304
+ onClick,
14305
+ disabled = false,
14306
+ title
13926
14307
  }) {
13927
14308
  return /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "cv-ctrl-item", children: [
13928
14309
  /* @__PURE__ */ jsxRuntime.jsx(
@@ -13934,6 +14315,8 @@ function Control({
13934
14315
  "aria-pressed": pressed,
13935
14316
  "aria-label": label,
13936
14317
  onClick,
14318
+ disabled,
14319
+ title,
13937
14320
  children: /* @__PURE__ */ jsxRuntime.jsx(Icon3, { size: 19 })
13938
14321
  }
13939
14322
  ),
@@ -13948,34 +14331,227 @@ var STATUS_LABEL = {
13948
14331
  reconnecting: "Reconectando\u2026",
13949
14332
  held: "En espera"
13950
14333
  };
14334
+ var CONSULT_FAIL_CODE = {
14335
+ busy: "call/busy",
14336
+ "no-answer": "call/no-answer",
14337
+ rejected: "call/rejected",
14338
+ failed: "call/failed"
14339
+ };
14340
+ function consultFailText(reason) {
14341
+ return ERROR_TEXT[CONSULT_FAIL_CODE[reason] ?? "call/transfer-failed"].title;
14342
+ }
14343
+ var MIN_CONSULT_HOLD_SECONDS = 10;
14344
+ function ConsultScreen({
14345
+ mainName,
14346
+ consultView,
14347
+ now,
14348
+ onReturn,
14349
+ onComplete,
14350
+ onHangupAll
14351
+ }) {
14352
+ const s = consultView.state;
14353
+ const answered = s === "active";
14354
+ const name = consultView.remote.displayName;
14355
+ const number = consultView.remote.number;
14356
+ const showNumber = number !== name;
14357
+ const heldSeconds = elapsedSeconds(consultView.answeredAt, now);
14358
+ const [rawM = "0", ss = "00"] = formatDuration(heldSeconds).split(":");
14359
+ const mm = rawM.padStart(2, "0");
14360
+ const canComplete = heldSeconds >= MIN_CONSULT_HOLD_SECONDS;
14361
+ return /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "cv-view", children: [
14362
+ /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "cv-call", children: [
14363
+ /* @__PURE__ */ jsxRuntime.jsxs("button", { type: "button", className: "cv-consult-hold-banner", onClick: onReturn, children: [
14364
+ /* @__PURE__ */ jsxRuntime.jsx(Pause, { size: 13, "aria-hidden": true }),
14365
+ mainName,
14366
+ " en espera \xB7 Volver"
14367
+ ] }),
14368
+ /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "cv-avatar-wrap", children: [
14369
+ /* @__PURE__ */ jsxRuntime.jsx("div", { className: "cv-avatar", "aria-hidden": true, children: initials(name) }),
14370
+ answered ? /* @__PURE__ */ jsxRuntime.jsx("span", { className: "cv-avatar-dot", "aria-hidden": true }) : null
14371
+ ] }),
14372
+ /* @__PURE__ */ jsxRuntime.jsx("div", { className: "cv-call-name", children: name }),
14373
+ showNumber ? /* @__PURE__ */ jsxRuntime.jsx("div", { className: "cv-call-number", children: number }) : null,
14374
+ answered ? /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "cv-timer", "aria-label": "Duraci\xF3n de la llamada", children: [
14375
+ /* @__PURE__ */ jsxRuntime.jsx("b", { children: mm }),
14376
+ /* @__PURE__ */ jsxRuntime.jsx("i", { children: ":" }),
14377
+ /* @__PURE__ */ jsxRuntime.jsx("b", { children: ss })
14378
+ ] }) : /* @__PURE__ */ jsxRuntime.jsx("div", { className: "cv-call-status", children: STATUS_LABEL[s] ?? "" })
14379
+ ] }),
14380
+ /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "cv-controls", children: [
14381
+ /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "cv-transfer-actions", children: [
14382
+ answered ? /* @__PURE__ */ jsxRuntime.jsx("button", { className: "cv-btn cv-btn--accent", onClick: onComplete, disabled: !canComplete, children: "Completar" }) : null,
14383
+ /* @__PURE__ */ jsxRuntime.jsx("button", { className: "cv-btn", onClick: onReturn, children: "Cancelar" })
14384
+ ] }),
14385
+ /* @__PURE__ */ jsxRuntime.jsx("div", { className: "cv-controls-primary", children: /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "cv-ctrl-item", children: [
14386
+ /* @__PURE__ */ jsxRuntime.jsx(
14387
+ "button",
14388
+ {
14389
+ type: "button",
14390
+ className: "cv-round cv-round--hangup",
14391
+ onClick: onHangupAll,
14392
+ "aria-label": "Colgar todo",
14393
+ children: /* @__PURE__ */ jsxRuntime.jsx(PhoneOff, { size: 26 })
14394
+ }
14395
+ ),
14396
+ /* @__PURE__ */ jsxRuntime.jsx("span", { className: "cv-ctrl-label", "aria-hidden": true, children: "Colgar todo" })
14397
+ ] }) })
14398
+ ] })
14399
+ ] });
14400
+ }
14401
+ var PRESENCE_LABEL = {
14402
+ disponible: "Disponible",
14403
+ en_llamada: "En llamada",
14404
+ desconectado: "Desconectado"
14405
+ };
14406
+ var PRESENCE_RANK = {
14407
+ disponible: 0,
14408
+ en_llamada: 1,
14409
+ desconectado: 2
14410
+ };
14411
+ function filterAgents(agents, query) {
14412
+ const q = query.trim().toLowerCase();
14413
+ return agents.filter((a) => !q || (a.name ?? "").toLowerCase().includes(q) || a.extension.includes(q)).sort(
14414
+ (a, b) => PRESENCE_RANK[a.state] - PRESENCE_RANK[b.state] || (a.name ?? a.extension).localeCompare(b.name ?? b.extension)
14415
+ );
14416
+ }
14417
+ function truncateForButton(text, max = 16) {
14418
+ return text.length > max ? `${text.slice(0, max)}\u2026` : text;
14419
+ }
14420
+ function TransferPicker({
14421
+ agents,
14422
+ myUserId,
14423
+ query,
14424
+ onSelect
14425
+ }) {
14426
+ const others = agents.filter((a) => a.userId !== myUserId);
14427
+ if (others.length === 0) return null;
14428
+ const filtered = filterAgents(others, query);
14429
+ return /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "cv-presence", children: [
14430
+ /* @__PURE__ */ jsxRuntime.jsx("div", { className: "cv-presence-list", role: "listbox", "aria-label": "Compa\xF1eros", children: filtered.length === 0 ? /* @__PURE__ */ jsxRuntime.jsx("p", { className: "cv-presence-empty", children: "Sin coincidencias" }) : filtered.map((agent) => {
14431
+ const isBusy = agent.state === "en_llamada";
14432
+ return /* @__PURE__ */ jsxRuntime.jsxs(
14433
+ "button",
14434
+ {
14435
+ type: "button",
14436
+ role: "option",
14437
+ className: "cv-presence-item",
14438
+ disabled: isBusy,
14439
+ onClick: () => onSelect(agent.extension),
14440
+ children: [
14441
+ /* @__PURE__ */ jsxRuntime.jsx("span", { className: "cv-presence-dot", "data-state": agent.state, "aria-hidden": true }),
14442
+ /* @__PURE__ */ jsxRuntime.jsx("span", { className: "cv-presence-name", children: agent.name ?? agent.extension }),
14443
+ /* @__PURE__ */ jsxRuntime.jsxs("span", { className: "cv-presence-meta", children: [
14444
+ agent.extension,
14445
+ " \xB7 ",
14446
+ PRESENCE_LABEL[agent.state]
14447
+ ] })
14448
+ ]
14449
+ },
14450
+ agent.userId
14451
+ );
14452
+ }) }),
14453
+ /* @__PURE__ */ jsxRuntime.jsx("p", { className: "cv-presence-legend", children: "Desconectado: sin conexi\xF3n en el softphone web. Puede atender desde otro dispositivo." })
14454
+ ] });
14455
+ }
13951
14456
  function CallScreen({
13952
14457
  call,
13953
- incomingCallAction
14458
+ incomingCallAction,
14459
+ keyTones = true
13954
14460
  }) {
13955
14461
  const view = useCall(call);
13956
- const [showTransfer, setShowTransfer] = React.useState(false);
14462
+ const softphone = useSoftphone();
14463
+ const [panel, setPanel] = React.useState(null);
14464
+ const showTransfer = panel === "transfer";
14465
+ const showKeypad = panel === "keypad";
14466
+ const showDevices = panel === "devices";
14467
+ const togglePanel = (p) => setPanel((cur) => cur === p ? null : p);
13957
14468
  const [transferTo, setTransferTo] = React.useState("");
13958
- const [showDevices, setShowDevices] = React.useState(false);
14469
+ const [consultCall, setConsultCall] = React.useState(null);
14470
+ const [consultOutcome, setConsultOutcome] = React.useState(null);
14471
+ const [myUserId, setMyUserId] = React.useState(null);
14472
+ const consultView = useCall(consultCall);
14473
+ const consultIntentRef = React.useRef(null);
13959
14474
  const controlsRef = React.useRef(null);
13960
14475
  const now = useTicker(view?.state === "active");
14476
+ const consultNow = useTicker(consultView?.state === "active");
13961
14477
  const isConnected = view ? view.state === "active" || view.state === "held" || view.state === "reconnecting" : false;
14478
+ const transferPickerActive = isConnected && showTransfer && !consultCall;
14479
+ const presence = usePresence(transferPickerActive);
14480
+ React.useEffect(() => {
14481
+ const listAgents = softphone.sources?.listAgents;
14482
+ if (!listAgents) return;
14483
+ let cancelled = false;
14484
+ void listAgents().then((d) => !cancelled && setMyUserId(d.me?.userId ?? null)).catch(() => {
14485
+ });
14486
+ return () => {
14487
+ cancelled = true;
14488
+ };
14489
+ }, [softphone]);
14490
+ React.useEffect(() => {
14491
+ if (!consultCall) return;
14492
+ return consultCall.on("ended" /* Ended */, ({ reason }) => {
14493
+ const intent = consultIntentRef.current;
14494
+ consultIntentRef.current = null;
14495
+ setConsultCall(null);
14496
+ if (!intent) {
14497
+ setConsultOutcome(reason);
14498
+ if (call.state !== "ended") void call.unhold();
14499
+ }
14500
+ });
14501
+ }, [consultCall, call]);
14502
+ const startConsult = async (target) => {
14503
+ const t = (target ?? transferTo).trim();
14504
+ if (!t) return;
14505
+ setConsultOutcome(null);
14506
+ setConsultCall(await call.startConsult(t));
14507
+ };
14508
+ const selectPresenceAgent = (extension) => {
14509
+ setTransferTo(extension);
14510
+ void startConsult(extension);
14511
+ };
14512
+ const [quickTransfer, setQuickTransfer] = React.useState(false);
14513
+ const selectPresenceAgentAuto = (extension) => {
14514
+ void call.transfer(extension);
14515
+ };
14516
+ const completeTransfer = async () => {
14517
+ if (!consultCall) return;
14518
+ const target = transferTo.trim();
14519
+ consultIntentRef.current = "complete";
14520
+ await consultCall.hangup();
14521
+ await call.transfer(target);
14522
+ };
14523
+ const cancelConsult = async () => {
14524
+ consultIntentRef.current = "cancel";
14525
+ await call.cancelConsult();
14526
+ };
13962
14527
  React.useEffect(() => {
13963
14528
  if (!showDevices) return;
13964
14529
  const onKey = (e) => {
13965
- if (e.key === "Escape") setShowDevices(false);
14530
+ if (e.key === "Escape") setPanel(null);
14531
+ };
14532
+ document.addEventListener("keydown", onKey);
14533
+ return () => document.removeEventListener("keydown", onKey);
14534
+ }, [showDevices]);
14535
+ React.useEffect(() => {
14536
+ if (!showKeypad) return;
14537
+ const onKey = (e) => {
14538
+ if (e.key === "Escape") setPanel(null);
13966
14539
  };
13967
14540
  document.addEventListener("keydown", onKey);
13968
14541
  if (!isConnected) return () => document.removeEventListener("keydown", onKey);
13969
14542
  const root = controlsRef.current?.getRootNode() ?? document;
13970
14543
  const onDown = (e) => {
13971
- if (!controlsRef.current?.contains(e.target)) setShowDevices(false);
14544
+ if (!controlsRef.current?.contains(e.target)) setPanel(null);
13972
14545
  };
13973
14546
  root.addEventListener("pointerdown", onDown);
13974
14547
  return () => {
13975
14548
  document.removeEventListener("keydown", onKey);
13976
14549
  root.removeEventListener("pointerdown", onDown);
13977
14550
  };
13978
- }, [showDevices, isConnected]);
14551
+ }, [showKeypad, isConnected]);
14552
+ React.useEffect(() => {
14553
+ if (view && view.state !== "active") setPanel((cur) => cur === "keypad" ? null : cur);
14554
+ }, [view?.state]);
13979
14555
  if (!view || view.state === "ended") return null;
13980
14556
  const s = view.state;
13981
14557
  const isRinging = s === "ringing";
@@ -13983,14 +14559,27 @@ function CallScreen({
13983
14559
  const name = view.remote.displayName;
13984
14560
  const number = view.remote.number;
13985
14561
  const showNumber = number !== name;
14562
+ if (consultCall && consultView) {
14563
+ return /* @__PURE__ */ jsxRuntime.jsx(
14564
+ ConsultScreen,
14565
+ {
14566
+ mainName: name,
14567
+ consultView,
14568
+ now: consultNow,
14569
+ onReturn: () => void cancelConsult(),
14570
+ onComplete: () => void completeTransfer(),
14571
+ onHangupAll: () => void call.hangup()
14572
+ }
14573
+ );
14574
+ }
13986
14575
  const incomingAction = isRinging && view.remote.contact && incomingCallAction ? incomingCallAction : null;
13987
14576
  const IncomingActionIcon = incomingAction?.icon ?? ExternalLink;
13988
14577
  const [rawM = "0", ss = "00"] = formatDuration(elapsedSeconds(view.answeredAt, now)).split(":");
13989
14578
  const mm = rawM.padStart(2, "0");
13990
14579
  return /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "cv-view", children: [
13991
- /* @__PURE__ */ jsxRuntime.jsx("div", { className: "cv-call", children: !inCall && showDevices ? /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "cv-call-devices-inline", children: [
14580
+ /* @__PURE__ */ jsxRuntime.jsx("div", { className: "cv-call", children: showDevices ? /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "cv-call-devices-inline", children: [
13992
14581
  /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "cv-call-devices-header", children: [
13993
- /* @__PURE__ */ jsxRuntime.jsxs("button", { type: "button", className: "cv-call-devices-chip", onClick: () => setShowDevices(false), children: [
14582
+ /* @__PURE__ */ jsxRuntime.jsxs("button", { type: "button", className: "cv-call-devices-chip", onClick: () => setPanel(null), children: [
13994
14583
  /* @__PURE__ */ jsxRuntime.jsx(ArrowLeft, { size: 13, "aria-hidden": true }),
13995
14584
  "Volver"
13996
14585
  ] }),
@@ -13998,10 +14587,82 @@ function CallScreen({
13998
14587
  ] }),
13999
14588
  /* @__PURE__ */ jsxRuntime.jsxs("p", { className: "cv-call-devices-context", children: [
14000
14589
  showNumber ? `${number} \xB7 ` : "",
14001
- STATUS_LABEL[s] ?? ""
14590
+ s === "active" ? `${mm}:${ss}` : STATUS_LABEL[s] ?? ""
14002
14591
  ] }),
14003
14592
  /* @__PURE__ */ jsxRuntime.jsx("div", { className: "cv-settings-section-body", children: /* @__PURE__ */ jsxRuntime.jsx(DevicePickers, {}) })
14004
- ] }) : /* @__PURE__ */ jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [
14593
+ ] }) : transferPickerActive ? (
14594
+ /*
14595
+ Found live (2026-09-29): the agent list a moment ago picked between showing ONE agent row
14596
+ or forcing a scroll — `.cv-presence-list`'s own `max-height` had already been trimmed twice
14597
+ (T7, then T3) chasing that, and both times the room it was chasing lived somewhere else.
14598
+ The full avatar/name/giant-timer block above stayed on screen, unconditionally, the WHOLE
14599
+ time the picker was open — exactly the anti-pattern `.cv-call-devices-inline` (above) was
14600
+ already built to avoid for the pre-connect device picker: "an inline swap, not an overlay,
14601
+ so it gets the full box, not a leftover sliver." The picker never got that same treatment.
14602
+ Measured live (a temporary harness, `playground/react/picker-harness.*`, real Chromium at
14603
+ this SDK's own real embed footprint, 310×520): the avatar+name+timer block alone cost
14604
+ ~150px of `.cv-call`'s ~340px of usable height — more than the entire agent list, the mode
14605
+ toggle, and the free-text row combined. Reusing `.cv-call-devices-inline`'s OWN "who's
14606
+ calling doesn't disappear" compact context line (below) instead of the full block gives
14607
+ that ~150px back to the one thing this screen is actually for right now: the list.
14608
+ */
14609
+ /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "cv-transfer", children: [
14610
+ /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "cv-transfer-context", children: [
14611
+ /* @__PURE__ */ jsxRuntime.jsx("span", { className: "cv-call-devices-title", children: name }),
14612
+ /* @__PURE__ */ jsxRuntime.jsxs("p", { className: "cv-call-devices-context", children: [
14613
+ showNumber ? `${number} \xB7 ` : "",
14614
+ s === "active" ? `${mm}:${ss}` : STATUS_LABEL[s] ?? ""
14615
+ ] })
14616
+ ] }),
14617
+ consultOutcome ? /* @__PURE__ */ jsxRuntime.jsx("p", { className: "cv-dial-outcome", role: "status", "data-shown": "true", children: consultFailText(consultOutcome) }) : null,
14618
+ /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "cv-transfer-row", children: [
14619
+ /* @__PURE__ */ jsxRuntime.jsx(
14620
+ "input",
14621
+ {
14622
+ className: "cv-search",
14623
+ value: transferTo,
14624
+ onChange: (e) => setTransferTo(e.target.value),
14625
+ placeholder: "Nombre, ext. o n\xFAmero",
14626
+ "aria-label": "Transferir a",
14627
+ autoFocus: true
14628
+ }
14629
+ ),
14630
+ /* @__PURE__ */ jsxRuntime.jsx(
14631
+ "button",
14632
+ {
14633
+ className: "cv-btn cv-btn--accent",
14634
+ disabled: !transferTo.trim(),
14635
+ onClick: () => void startConsult(),
14636
+ children: transferTo.trim() ? `Llamar a ${truncateForButton(transferTo.trim())}` : "Llamar"
14637
+ }
14638
+ )
14639
+ ] }),
14640
+ /* @__PURE__ */ jsxRuntime.jsx("div", { className: "cv-segmented", role: "tablist", "aria-label": "Modo de transferencia", children: [
14641
+ { key: "consult", label: "Hablar primero" },
14642
+ { key: "auto", label: "Transferir directo" }
14643
+ ].map(({ key, label }) => /* @__PURE__ */ jsxRuntime.jsx(
14644
+ "button",
14645
+ {
14646
+ type: "button",
14647
+ role: "tab",
14648
+ "aria-selected": quickTransfer === (key === "auto"),
14649
+ className: "cv-segmented-btn",
14650
+ onClick: () => setQuickTransfer(key === "auto"),
14651
+ children: label
14652
+ },
14653
+ key
14654
+ )) }),
14655
+ /* @__PURE__ */ jsxRuntime.jsx(
14656
+ TransferPicker,
14657
+ {
14658
+ agents: presence,
14659
+ myUserId,
14660
+ query: transferTo,
14661
+ onSelect: quickTransfer ? selectPresenceAgentAuto : selectPresenceAgent
14662
+ }
14663
+ )
14664
+ ] })
14665
+ ) : /* @__PURE__ */ jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [
14005
14666
  /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "cv-avatar-wrap", children: [
14006
14667
  isRinging ? /* @__PURE__ */ jsxRuntime.jsx("span", { className: "cv-avatar-ping", "aria-hidden": true }) : null,
14007
14668
  /* @__PURE__ */ jsxRuntime.jsx("div", { className: "cv-avatar", "aria-hidden": true, children: initials(name) }),
@@ -14014,7 +14675,7 @@ function CallScreen({
14014
14675
  /* @__PURE__ */ jsxRuntime.jsx("i", { children: ":" }),
14015
14676
  /* @__PURE__ */ jsxRuntime.jsx("b", { children: ss })
14016
14677
  ] }) : /* @__PURE__ */ jsxRuntime.jsx("div", { className: "cv-call-status", children: s === "held" ? /* @__PURE__ */ jsxRuntime.jsx("span", { className: "cv-badge-hold", children: STATUS_LABEL[s] }) : STATUS_LABEL[s] ?? "" }),
14017
- !inCall ? /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "cv-call-chips", children: [
14678
+ /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "cv-call-chips", children: [
14018
14679
  incomingAction ? /* @__PURE__ */ jsxRuntime.jsxs(
14019
14680
  "button",
14020
14681
  {
@@ -14027,36 +14688,11 @@ function CallScreen({
14027
14688
  ]
14028
14689
  }
14029
14690
  ) : null,
14030
- /* @__PURE__ */ jsxRuntime.jsxs("button", { type: "button", className: "cv-call-devices-chip", onClick: () => setShowDevices(true), children: [
14691
+ /* @__PURE__ */ jsxRuntime.jsxs("button", { type: "button", className: "cv-call-devices-chip", onClick: () => setPanel("devices"), children: [
14031
14692
  /* @__PURE__ */ jsxRuntime.jsx(Headphones, { size: 13, "aria-hidden": true }),
14032
14693
  "Dispositivos"
14033
14694
  ] })
14034
- ] }) : null,
14035
- inCall && showTransfer ? /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "cv-transfer", children: [
14036
- /* @__PURE__ */ jsxRuntime.jsx(
14037
- "input",
14038
- {
14039
- className: "cv-search",
14040
- value: transferTo,
14041
- onChange: (e) => setTransferTo(e.target.value),
14042
- placeholder: "Extensi\xF3n o n\xFAmero",
14043
- "aria-label": "Transferir a",
14044
- autoFocus: true
14045
- }
14046
- ),
14047
- /* @__PURE__ */ jsxRuntime.jsx(
14048
- "button",
14049
- {
14050
- className: "cv-btn cv-btn--accent",
14051
- disabled: !transferTo.trim(),
14052
- onClick: () => {
14053
- void call.transfer(transferTo.trim());
14054
- setShowTransfer(false);
14055
- },
14056
- children: "Transferir"
14057
- }
14058
- )
14059
- ] }) : null
14695
+ ] })
14060
14696
  ] }) }),
14061
14697
  /* @__PURE__ */ jsxRuntime.jsx("div", { className: "cv-controls", ref: controlsRef, children: isRinging ? /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "cv-controls-primary", children: [
14062
14698
  /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "cv-ctrl-item", children: [
@@ -14115,17 +14751,19 @@ function CallScreen({
14115
14751
  label: "Transferir",
14116
14752
  pressed: showTransfer,
14117
14753
  tone: "accent",
14118
- onClick: () => setShowTransfer((v) => !v)
14754
+ onClick: () => togglePanel("transfer")
14119
14755
  }
14120
14756
  ),
14121
14757
  /* @__PURE__ */ jsxRuntime.jsx(
14122
14758
  Control,
14123
14759
  {
14124
- icon: Headphones,
14125
- label: "Dispositivos",
14126
- pressed: showDevices,
14760
+ icon: Grid3x3,
14761
+ label: "Teclado",
14762
+ pressed: showKeypad,
14127
14763
  tone: "accent",
14128
- onClick: () => setShowDevices((v) => !v)
14764
+ disabled: s !== "active",
14765
+ title: s === "held" ? "Reanuda la llamada para usar el teclado" : s === "reconnecting" ? "No disponible mientras se reconecta" : void 0,
14766
+ onClick: () => togglePanel("keypad")
14129
14767
  }
14130
14768
  )
14131
14769
  ] }) : null,
@@ -14143,7 +14781,22 @@ function CallScreen({
14143
14781
  ),
14144
14782
  /* @__PURE__ */ jsxRuntime.jsx("span", { className: "cv-ctrl-label", "aria-hidden": true, children: "Colgar" })
14145
14783
  ] }) }),
14146
- inCall && showDevices ? /* @__PURE__ */ jsxRuntime.jsx("div", { className: "cv-device-popover cv-device-popover--above", role: "dialog", "aria-label": "Dispositivos de audio", children: /* @__PURE__ */ jsxRuntime.jsx(DevicePickers, {}) }) : null
14784
+ inCall && showKeypad ? /* @__PURE__ */ jsxRuntime.jsx("div", { className: "cv-keypad-popover cv-keypad-popover--above", role: "dialog", "aria-label": "Teclado", children: KEYPAD_KEYS.map(([key, letters]) => /* @__PURE__ */ jsxRuntime.jsxs(
14785
+ "button",
14786
+ {
14787
+ type: "button",
14788
+ className: "cv-key",
14789
+ onClick: () => {
14790
+ call.sendDigits(key);
14791
+ if (keyTones) playKeyTone(key);
14792
+ },
14793
+ children: [
14794
+ /* @__PURE__ */ jsxRuntime.jsx("b", { children: key }),
14795
+ letters ? /* @__PURE__ */ jsxRuntime.jsx("small", { children: letters }) : null
14796
+ ]
14797
+ },
14798
+ key
14799
+ )) }) : null
14147
14800
  ] }) })
14148
14801
  ] });
14149
14802
  }
@@ -14862,117 +15515,6 @@ function MicPermissionGate({ children }) {
14862
15515
  if (state === "ready") return /* @__PURE__ */ jsxRuntime.jsx(jsxRuntime.Fragment, { children });
14863
15516
  return /* @__PURE__ */ jsxRuntime.jsx("div", { className: "cv-softphone", children: /* @__PURE__ */ jsxRuntime.jsx(MicPrompt, { state, onRequest: () => void request() }) });
14864
15517
  }
14865
-
14866
- // src/ui/lib/error-text.ts
14867
- var ERROR_TEXT = {
14868
- // ── Engine lifecycle ────────────────────────────────────────────────────────
14869
- // Integrator mistakes rather than user-facing failures, but they must still read as something
14870
- // rather than as a blank panel if one ever reaches the UI.
14871
- "engine/duplicate-instance": {
14872
- title: "Tel\xE9fono duplicado",
14873
- body: "Hay otro tel\xE9fono abierto en esta p\xE1gina. Cierra uno y vuelve a cargar."
14874
- },
14875
- "engine/not-started": { title: "El tel\xE9fono a\xFAn no est\xE1 listo" },
14876
- "engine/destroyed": {
14877
- title: "Tel\xE9fono cerrado",
14878
- body: "Vuelve a cargar la p\xE1gina para usarlo de nuevo."
14879
- },
14880
- // ── Auth ────────────────────────────────────────────────────────────────────
14881
- // Never "token": the user has no idea what one is. What they can act on is the session.
14882
- "auth/token-fetch-failed": {
14883
- title: "Sin conexi\xF3n con el servidor",
14884
- body: "No se ha podido activar el tel\xE9fono. Reint\xE9ntalo en unos segundos."
14885
- },
14886
- "auth/token-rejected": {
14887
- title: "Sesi\xF3n caducada",
14888
- body: "Vuelve a iniciar sesi\xF3n para poder llamar."
14889
- },
14890
- // ── Media ───────────────────────────────────────────────────────────────────
14891
- // Verbatim from MicPermissionGate, which already says these well and says them first.
14892
- //
14893
- // NEVER ACTUALLY SHOWN via this table — these four codes (through `media/playback-blocked`, below)
14894
- // are never raised as a `SoftphoneError` at all. See the fuller comment on each in `core/errors.ts`
14895
- // for why. Copy kept here anyway so the catalogue and this file stay the same shape as every other
14896
- // code — `error-text.test.ts` would fail the build the moment they drifted.
14897
- "media/permission-denied": {
14898
- title: "Micr\xF3fono bloqueado",
14899
- body: "Perm\xEDtelo en tu navegador para poder llamar."
14900
- },
14901
- "media/no-input-device": {
14902
- title: "Sin micr\xF3fono",
14903
- body: "Conecta un micr\xF3fono y vuelve a intentarlo."
14904
- },
14905
- "media/webrtc-unsupported": {
14906
- title: "Llamadas no disponibles",
14907
- body: "Este navegador no admite llamadas. Prueba con Chrome, Edge o Firefox."
14908
- },
14909
- "media/playback-blocked": { title: "Toca la pantalla para activar el sonido" },
14910
- // ── Network / signalling ────────────────────────────────────────────────────
14911
- // "Centralita" rather than "SIP" or "registro": the user knows what a phone system is.
14912
- "net/registration-failed": {
14913
- title: "No se pudo conectar",
14914
- body: "No hemos podido conectar con la centralita. Reintenta en unos segundos."
14915
- },
14916
- "net/transport-failed": {
14917
- title: "Conexi\xF3n perdida",
14918
- body: "Se ha perdido la conexi\xF3n con la centralita."
14919
- },
14920
- // NEVER ACTUALLY SHOWN either — CallScreen's own "Reconectando…" already covers this, off the
14921
- // call's `reconnecting` state rather than this channel. See the comment in `core/errors.ts`.
14922
- "net/ice-failed": { title: "Recuperando el audio\u2026" },
14923
- // ── Call ────────────────────────────────────────────────────────────────────
14924
- /*
14925
- THE VOICE RULE FOR THIS BLOCK: say what HAPPENED, not what it is called.
14926
-
14927
- These were status labels — "Llamada perdida", "Llamada rechazada", "Sin respuesta". Each is a
14928
- name for a category rather than an account of an event, which is why they read as a database enum
14929
- someone forgot to translate into human. Worse, a label hides the ACTOR: "Llamada rechazada" never
14930
- says who rejected what, so the user reading it after declining a call and the user reading it
14931
- after being declined see the same three words.
14932
-
14933
- So each one is a short sentence about what the other person, or the phone, did — the form
14934
- `call/busy` already had, and the reason it was the only one here that read clearly.
14935
-
14936
- Impersonal third-person plural ("contestaron", "colgaron") throughout: the user does not know who
14937
- is at the other end, and it is what Spanish uses when the subject is unknown. Mixing it with the
14938
- singular across neighbouring lines reads as carelessness.
14939
-
14940
- LENGTH IS A HARD CONSTRAINT, not a preference. `.cv-dial-outcome` is one line at a fixed
14941
- `1.15rem`, and the keypad below it is `flex: 1` — a title that wraps resizes every key and shifts
14942
- the grid under a finger already reaching for it. `error-text.test.ts` caps this at 48 characters.
14943
- */
14944
- "call/not-answerable": { title: "Esa llamada ya ha terminado" },
14945
- /*
14946
- NOT "Comunicando", which is what this said and what sent us looking. SIP 486 is both "the line is
14947
- engaged" AND what almost every endpoint sends when a person presses decline — including our own
14948
- `reject()`. One code, two events, no way to tell them apart, so the copy has to hold both rather
14949
- than assert the less likely one. `call/rejected` can be definite: an endpoint sending 603 Decline
14950
- means exactly that.
14951
- */
14952
- "call/busy": { title: "No contestaron o la l\xEDnea estaba ocupada" },
14953
- "call/no-answer": { title: "No contestaron" },
14954
- "call/rejected": { title: "No aceptaron tu llamada" },
14955
- /*
14956
- The legacy CRM said "La persona que llamaba colgó antes de que pudieras contestar." — the right
14957
- fact, at three times the width of the line that has to hold it. This keeps the fact and drops the
14958
- words: the user needs to know the call was FOR them and that nobody is waiting on the line.
14959
- "Llamada perdida" alone tested badly for exactly the label reason above — it names the category
14960
- without saying anyone rang.
14961
- */
14962
- "call/missed": { title: "Colgaron antes de que pudieras contestar" },
14963
- // "No se pudo completar la llamada" was our own "something went wrong": true of every failure and
14964
- // useful for none. The number is what the user can actually act on.
14965
- "call/failed": { title: "No se pudo conectar con ese n\xFAmero" },
14966
- // The load-bearing half is the SECOND sentence: the original call is still up, and a user who
14967
- // thinks they dropped it will hang up on a client to "fix" it.
14968
- "call/transfer-failed": { title: "No se pudo transferir. Sigues en la llamada" },
14969
- "call/unsupported": { title: "Esta funci\xF3n no est\xE1 disponible" },
14970
- // ── Provider ────────────────────────────────────────────────────────────────
14971
- "provider/provision-failed": {
14972
- title: "No se pudo activar el tel\xE9fono",
14973
- body: "Si el problema contin\xFAa, contacta con soporte."
14974
- }
14975
- };
14976
15518
  function ErrorGate({
14977
15519
  error,
14978
15520
  onRetry
@@ -15029,203 +15571,6 @@ function useCallOutcome() {
15029
15571
  clear
15030
15572
  };
15031
15573
  }
15032
-
15033
- // src/ui/audio/audio.ts
15034
- var TONE_HZ = 425;
15035
- function createRemoteAudio() {
15036
- const el = document.createElement("audio");
15037
- el.autoplay = true;
15038
- el.style.display = "none";
15039
- document.body.appendChild(el);
15040
- return {
15041
- attach(stream) {
15042
- el.srcObject = stream;
15043
- void el.play().catch(() => {
15044
- });
15045
- },
15046
- detach() {
15047
- el.srcObject = null;
15048
- },
15049
- destroy() {
15050
- el.srcObject = null;
15051
- el.remove();
15052
- },
15053
- async setOutputDevice(deviceId) {
15054
- if (typeof el.setSinkId !== "function") return;
15055
- try {
15056
- await el.setSinkId(deviceId ?? "");
15057
- } catch {
15058
- }
15059
- }
15060
- };
15061
- }
15062
- var shared = null;
15063
- function sharedContext() {
15064
- if (shared) return shared;
15065
- const Ctor = typeof AudioContext !== "undefined" ? AudioContext : globalThis.webkitAudioContext;
15066
- if (!Ctor) return null;
15067
- shared = new Ctor();
15068
- return shared;
15069
- }
15070
- function primeAudio() {
15071
- const ctx = sharedContext();
15072
- if (ctx && ctx.state === "suspended") void ctx.resume?.().catch(() => {
15073
- });
15074
- }
15075
- function playRingtone() {
15076
- const ctx = sharedContext();
15077
- if (!ctx) return { stop() {
15078
- } };
15079
- const gain = ctx.createGain();
15080
- gain.gain.value = 0;
15081
- gain.connect(ctx.destination);
15082
- const osc = ctx.createOscillator();
15083
- osc.type = "sine";
15084
- osc.frequency.value = TONE_HZ;
15085
- osc.connect(gain);
15086
- osc.start();
15087
- const LEVEL = 0.8;
15088
- const RAMP = 0.02;
15089
- const burst = (at, dur) => {
15090
- gain.gain.setValueAtTime(0, at);
15091
- gain.gain.linearRampToValueAtTime(LEVEL, at + RAMP);
15092
- gain.gain.setValueAtTime(LEVEL, at + dur - RAMP);
15093
- gain.gain.linearRampToValueAtTime(0, at + dur);
15094
- };
15095
- const scheduleCycle = (at) => {
15096
- burst(at, 0.4);
15097
- burst(at + 0.6, 0.4);
15098
- return 3;
15099
- };
15100
- const LOOKAHEAD = 0.5;
15101
- const TICK_MS = 150;
15102
- const LEAD = 0.08;
15103
- let stopped = false;
15104
- let timer = null;
15105
- let nextAt = 0;
15106
- const pump = () => {
15107
- while (nextAt < ctx.currentTime + LOOKAHEAD) nextAt += scheduleCycle(nextAt);
15108
- };
15109
- void Promise.resolve(ctx.resume?.()).catch(() => {
15110
- }).finally(() => {
15111
- if (stopped) return;
15112
- nextAt = ctx.currentTime + LEAD;
15113
- pump();
15114
- timer = setInterval(pump, TICK_MS);
15115
- });
15116
- return {
15117
- stop() {
15118
- stopped = true;
15119
- if (timer) clearInterval(timer);
15120
- try {
15121
- osc.stop();
15122
- } catch {
15123
- }
15124
- try {
15125
- gain.disconnect();
15126
- } catch {
15127
- }
15128
- }
15129
- };
15130
- }
15131
- function playCallEndedTone() {
15132
- const ctx = sharedContext();
15133
- if (!ctx) return;
15134
- const gain = ctx.createGain();
15135
- gain.gain.value = 0;
15136
- gain.connect(ctx.destination);
15137
- const osc = ctx.createOscillator();
15138
- osc.type = "sine";
15139
- osc.frequency.value = TONE_HZ;
15140
- osc.connect(gain);
15141
- osc.start();
15142
- const LEVEL = 0.55;
15143
- const RAMP = 8e-3;
15144
- const BEEP = 0.25;
15145
- void Promise.resolve(ctx.resume?.()).catch(() => {
15146
- }).finally(() => {
15147
- const at = ctx.currentTime + 0.02;
15148
- gain.gain.setValueAtTime(0, at);
15149
- gain.gain.linearRampToValueAtTime(LEVEL, at + RAMP);
15150
- gain.gain.setValueAtTime(LEVEL, at + BEEP - RAMP);
15151
- gain.gain.linearRampToValueAtTime(0, at + BEEP);
15152
- const totalMs = (at + BEEP - ctx.currentTime) * 1e3;
15153
- setTimeout(() => {
15154
- try {
15155
- osc.stop();
15156
- } catch {
15157
- }
15158
- try {
15159
- gain.disconnect();
15160
- } catch {
15161
- }
15162
- }, totalMs);
15163
- });
15164
- }
15165
- var ringbackUrl = null;
15166
- function ringbackSource() {
15167
- if (ringbackUrl) return ringbackUrl;
15168
- const sampleRate = 48e3;
15169
- const channels = 2;
15170
- const bytesPerSample = 2;
15171
- const amp = 0.5;
15172
- const toneSec = 1.5;
15173
- const silenceSec = 3;
15174
- const totalFrames = Math.round((toneSec + silenceSec) * sampleRate);
15175
- const toneFrames = Math.round(toneSec * sampleRate);
15176
- const ramp = Math.round(0.02 * sampleRate);
15177
- const blockAlign = channels * bytesPerSample;
15178
- const dataSize = totalFrames * blockAlign;
15179
- const buffer = new ArrayBuffer(44 + dataSize);
15180
- const view = new DataView(buffer);
15181
- const str = (off, s) => {
15182
- for (let i = 0; i < s.length; i++) view.setUint8(off + i, s.charCodeAt(i));
15183
- };
15184
- str(0, "RIFF");
15185
- view.setUint32(4, 36 + dataSize, true);
15186
- str(8, "WAVE");
15187
- str(12, "fmt ");
15188
- view.setUint32(16, 16, true);
15189
- view.setUint16(20, 1, true);
15190
- view.setUint16(22, channels, true);
15191
- view.setUint32(24, sampleRate, true);
15192
- view.setUint32(28, sampleRate * blockAlign, true);
15193
- view.setUint16(32, blockAlign, true);
15194
- view.setUint16(34, bytesPerSample * 8, true);
15195
- str(36, "data");
15196
- view.setUint32(40, dataSize, true);
15197
- for (let i = 0; i < totalFrames; i++) {
15198
- let sample = 0;
15199
- if (i < toneFrames) {
15200
- let env = 1;
15201
- if (i < ramp) env = i / ramp;
15202
- else if (i > toneFrames - ramp) env = Math.max(0, (toneFrames - i) / ramp);
15203
- sample = Math.sin(2 * Math.PI * TONE_HZ * (i / sampleRate)) * amp * env;
15204
- }
15205
- const s16 = Math.round(sample * 32767);
15206
- const off = 44 + i * blockAlign;
15207
- for (let c = 0; c < channels; c++) view.setInt16(off + c * bytesPerSample, s16, true);
15208
- }
15209
- ringbackUrl = URL.createObjectURL(new Blob([buffer], { type: "audio/wav" }));
15210
- return ringbackUrl;
15211
- }
15212
- function playRingback() {
15213
- const el = document.createElement("audio");
15214
- el.src = ringbackSource();
15215
- el.loop = true;
15216
- el.style.display = "none";
15217
- document.body.appendChild(el);
15218
- void el.play().catch(() => {
15219
- });
15220
- return {
15221
- stop() {
15222
- el.pause();
15223
- el.remove();
15224
- }
15225
- };
15226
- }
15227
-
15228
- // src/ui/audio/useCallAudio.ts
15229
15574
  function useCallAudio(enabled) {
15230
15575
  const softphone = useSoftphone();
15231
15576
  const remote = React.useRef(null);
@@ -15304,6 +15649,94 @@ function useCallAudio(enabled) {
15304
15649
  };
15305
15650
  }, [softphone, enabled]);
15306
15651
  }
15652
+ function TransferNotices({
15653
+ notices,
15654
+ dismiss
15655
+ }) {
15656
+ const softphone = useSoftphone();
15657
+ const now = useTicker(notices.length > 0, 15e3);
15658
+ const callBack = (notice) => {
15659
+ dismiss(notice.id);
15660
+ if (notice.caller) void softphone.call(notice.caller).catch(() => {
15661
+ });
15662
+ };
15663
+ return /* @__PURE__ */ jsxRuntime.jsxs(
15664
+ "div",
15665
+ {
15666
+ className: "cv-transfer-notices",
15667
+ role: "status",
15668
+ part: "transfer-notices",
15669
+ "data-has-notices": notices.length > 0,
15670
+ children: [
15671
+ notices.length > 1 ? /* @__PURE__ */ jsxRuntime.jsxs("p", { className: "cv-transfer-notices-count", children: [
15672
+ notices.length,
15673
+ " traspasos sin conectar"
15674
+ ] }) : null,
15675
+ notices.map((notice) => /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "cv-transfer-notice", part: "transfer-notice", children: [
15676
+ /* @__PURE__ */ jsxRuntime.jsx("span", { className: "cv-transfer-notice-mark", "aria-hidden": true, children: /* @__PURE__ */ jsxRuntime.jsx(PhoneMissed, { size: 16 }) }),
15677
+ /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "cv-transfer-notice-body", children: [
15678
+ /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "cv-transfer-notice-header", children: [
15679
+ /* @__PURE__ */ jsxRuntime.jsxs("p", { className: "cv-transfer-notice-title", children: [
15680
+ "El traspaso a ",
15681
+ notice.target,
15682
+ " no se conect\xF3"
15683
+ ] }),
15684
+ /* @__PURE__ */ jsxRuntime.jsx(
15685
+ "button",
15686
+ {
15687
+ type: "button",
15688
+ className: "cv-transfer-notice-dismiss",
15689
+ onClick: () => dismiss(notice.id),
15690
+ "aria-label": "Descartar aviso",
15691
+ part: "transfer-notice-dismiss",
15692
+ children: /* @__PURE__ */ jsxRuntime.jsx(X, { size: 14 })
15693
+ }
15694
+ )
15695
+ ] }),
15696
+ notice.caller ? /* @__PURE__ */ jsxRuntime.jsxs("p", { className: "cv-transfer-notice-subtitle", children: [
15697
+ notice.caller,
15698
+ " se ha desconectado."
15699
+ ] }) : null,
15700
+ /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "cv-transfer-notice-footer", children: [
15701
+ /* @__PURE__ */ jsxRuntime.jsx("p", { className: "cv-transfer-notice-age", children: formatAge(elapsedSeconds(notice.at, now)) }),
15702
+ notice.caller ? /* @__PURE__ */ jsxRuntime.jsxs(
15703
+ "button",
15704
+ {
15705
+ type: "button",
15706
+ className: "cv-btn cv-btn--accent cv-transfer-notice-callback",
15707
+ onClick: () => callBack(notice),
15708
+ part: "transfer-notice-callback",
15709
+ children: [
15710
+ /* @__PURE__ */ jsxRuntime.jsx(Phone, { size: 14, "aria-hidden": true }),
15711
+ "Llamar de vuelta"
15712
+ ]
15713
+ }
15714
+ ) : null
15715
+ ] })
15716
+ ] })
15717
+ ] }, notice.id))
15718
+ ]
15719
+ }
15720
+ );
15721
+ }
15722
+ function useTransferNotices() {
15723
+ const softphone = useSoftphone();
15724
+ const [notices, setNotices] = React.useState([]);
15725
+ const nextId = React.useRef(0);
15726
+ const dismiss = React.useCallback((id) => {
15727
+ setNotices((prev) => prev.filter((n) => n.id !== id));
15728
+ }, []);
15729
+ React.useEffect(
15730
+ () => softphone.on("transferOutcome" /* TransferOutcome */, (payload) => {
15731
+ if (payload.status === "not-connected") {
15732
+ const id = nextId.current++;
15733
+ setNotices((prev) => [...prev, { id, target: payload.target, caller: payload.caller, at: payload.at }]);
15734
+ }
15735
+ }),
15736
+ [softphone]
15737
+ );
15738
+ return { notices, dismiss };
15739
+ }
15307
15740
  function SoftphoneWidget(props) {
15308
15741
  const {
15309
15742
  contacts,
@@ -15328,7 +15761,9 @@ function SoftphoneWidget(props) {
15328
15761
  useCallAudio(sounds);
15329
15762
  const blocked = requireMic && mic.state !== "ready";
15330
15763
  const fatal = useLastError();
15764
+ const screenIsFree = !call && !blocked && !fatal && !isFollower;
15331
15765
  const { outcome, clear: clearOutcome } = useCallOutcome();
15766
+ const { notices: transferNotices, dismiss: dismissTransferNotice } = useTransferNotices();
15332
15767
  const retry = () => {
15333
15768
  void (async () => {
15334
15769
  try {
@@ -15352,8 +15787,9 @@ function SoftphoneWidget(props) {
15352
15787
  className: className ? `cv-softphone ${className}` : "cv-softphone",
15353
15788
  ...theme === "system" ? {} : { "data-cv-theme": theme },
15354
15789
  children: [
15355
- /* @__PURE__ */ jsxRuntime.jsx("div", { className: "cv-body", children: isFollower ? /* @__PURE__ */ jsxRuntime.jsx(LeaderGate, {}) : blocked ? /* @__PURE__ */ jsxRuntime.jsx(MicPrompt, { state: mic.state, onRequest: () => void mic.request() }) : call ? /* @__PURE__ */ jsxRuntime.jsx(CallScreen, { call, ...incomingCallAction ? { incomingCallAction } : {} }) : fatal ? /* @__PURE__ */ jsxRuntime.jsx(ErrorGate, { error: fatal, onRetry: retry }) : tab === "contacts" && contactsSource ? /* @__PURE__ */ jsxRuntime.jsx(ContactsTab, { source: contactsSource, ...contactAction ? { contactAction } : {} }) : tab === "history" && historySource ? /* @__PURE__ */ jsxRuntime.jsx(HistoryTab, { source: historySource }) : tab === "settings" ? /* @__PURE__ */ jsxRuntime.jsx(SettingsTab, {}) : /* @__PURE__ */ jsxRuntime.jsx(Dialpad, { outcome, onIntent: clearOutcome }) }),
15356
- call || blocked || fatal || isFollower ? null : /* @__PURE__ */ jsxRuntime.jsx("div", { className: "cv-tabs", role: "tablist", children: tabs.map(({ id, icon: Icon3, label }) => /* @__PURE__ */ jsxRuntime.jsxs(
15790
+ screenIsFree ? /* @__PURE__ */ jsxRuntime.jsx(TransferNotices, { notices: transferNotices, dismiss: dismissTransferNotice }) : null,
15791
+ /* @__PURE__ */ jsxRuntime.jsx("div", { className: "cv-body", children: isFollower ? /* @__PURE__ */ jsxRuntime.jsx(LeaderGate, {}) : blocked ? /* @__PURE__ */ jsxRuntime.jsx(MicPrompt, { state: mic.state, onRequest: () => void mic.request() }) : call ? /* @__PURE__ */ jsxRuntime.jsx(CallScreen, { call, keyTones: sounds, ...incomingCallAction ? { incomingCallAction } : {} }) : fatal ? /* @__PURE__ */ jsxRuntime.jsx(ErrorGate, { error: fatal, onRetry: retry }) : tab === "contacts" && contactsSource ? /* @__PURE__ */ jsxRuntime.jsx(ContactsTab, { source: contactsSource, ...contactAction ? { contactAction } : {} }) : tab === "history" && historySource ? /* @__PURE__ */ jsxRuntime.jsx(HistoryTab, { source: historySource }) : tab === "settings" ? /* @__PURE__ */ jsxRuntime.jsx(SettingsTab, {}) : /* @__PURE__ */ jsxRuntime.jsx(Dialpad, { outcome, onIntent: clearOutcome, keyTones: sounds }) }),
15792
+ screenIsFree ? /* @__PURE__ */ jsxRuntime.jsx("div", { className: "cv-tabs", role: "tablist", children: tabs.map(({ id, icon: Icon3, label }) => /* @__PURE__ */ jsxRuntime.jsxs(
15357
15793
  "button",
15358
15794
  {
15359
15795
  role: "tab",
@@ -15366,7 +15802,7 @@ function SoftphoneWidget(props) {
15366
15802
  ]
15367
15803
  },
15368
15804
  id
15369
- )) })
15805
+ )) }) : null
15370
15806
  ]
15371
15807
  }
15372
15808
  );