@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.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { isWebRtcSupported } from '../chunk-KAXEMJ7E.js';
2
- import { useSoftphone, useSoftphoneState, useCall, useActiveCall, useIsLeader, useLastError, useDevices } from '../chunk-3V2L6UTK.js';
3
- import '../chunk-N2N2EQQJ.js';
2
+ import { useSoftphone, useSoftphoneState, useCall, usePresence, useActiveCall, useIsLeader, useLastError, useDevices } from '../chunk-LMJXX4YM.js';
3
+ import '../chunk-7R6LOOAI.js';
4
4
  import { forwardRef, createElement, useState, useRef, useLayoutEffect, useEffect, useCallback, useMemo } from 'react';
5
5
  import { jsxs, jsx, Fragment } from 'react/jsx-runtime';
6
6
 
@@ -550,21 +550,263 @@ function caretAfterDigits(formatted, digitsBefore) {
550
550
  function digitsBeforeCaret(text, caret) {
551
551
  return (text.slice(0, caret).match(/\d/g) ?? []).length;
552
552
  }
553
- var KEYS = [
554
- ["1", "", true],
555
- ["2", "ABC", true],
556
- ["3", "DEF", true],
557
- ["4", "GHI", true],
558
- ["5", "JKL", true],
559
- ["6", "MNO", true],
560
- ["7", "PQRS", true],
561
- ["8", "TUV", true],
562
- ["9", "WXYZ", true],
563
- ["*", "", false],
564
- ["0", "+", true],
565
- ["#", "", false]
553
+
554
+ // src/ui/dialing/keys.ts
555
+ var KEYPAD_KEYS = [
556
+ ["1", ""],
557
+ ["2", "ABC"],
558
+ ["3", "DEF"],
559
+ ["4", "GHI"],
560
+ ["5", "JKL"],
561
+ ["6", "MNO"],
562
+ ["7", "PQRS"],
563
+ ["8", "TUV"],
564
+ ["9", "WXYZ"],
565
+ ["*", ""],
566
+ ["0", "+"],
567
+ ["#", ""]
566
568
  ];
567
- function Dialpad({ outcome = null, onIntent } = {}) {
569
+
570
+ // src/ui/audio/audio.ts
571
+ var TONE_HZ = 425;
572
+ function createRemoteAudio() {
573
+ const el = document.createElement("audio");
574
+ el.autoplay = true;
575
+ el.style.display = "none";
576
+ document.body.appendChild(el);
577
+ return {
578
+ attach(stream) {
579
+ el.srcObject = stream;
580
+ void el.play().catch(() => {
581
+ });
582
+ },
583
+ detach() {
584
+ el.srcObject = null;
585
+ },
586
+ destroy() {
587
+ el.srcObject = null;
588
+ el.remove();
589
+ },
590
+ async setOutputDevice(deviceId) {
591
+ if (typeof el.setSinkId !== "function") return;
592
+ try {
593
+ await el.setSinkId(deviceId ?? "");
594
+ } catch {
595
+ }
596
+ }
597
+ };
598
+ }
599
+ var shared = null;
600
+ function sharedContext() {
601
+ if (shared) return shared;
602
+ const Ctor = typeof AudioContext !== "undefined" ? AudioContext : globalThis.webkitAudioContext;
603
+ if (!Ctor) return null;
604
+ shared = new Ctor();
605
+ return shared;
606
+ }
607
+ function primeAudio() {
608
+ const ctx = sharedContext();
609
+ if (ctx && ctx.state === "suspended") void ctx.resume?.().catch(() => {
610
+ });
611
+ }
612
+ function playRingtone() {
613
+ const ctx = sharedContext();
614
+ if (!ctx) return { stop() {
615
+ } };
616
+ const gain = ctx.createGain();
617
+ gain.gain.value = 0;
618
+ gain.connect(ctx.destination);
619
+ const osc = ctx.createOscillator();
620
+ osc.type = "sine";
621
+ osc.frequency.value = TONE_HZ;
622
+ osc.connect(gain);
623
+ osc.start();
624
+ const LEVEL = 0.8;
625
+ const RAMP = 0.02;
626
+ const burst = (at, dur) => {
627
+ gain.gain.setValueAtTime(0, at);
628
+ gain.gain.linearRampToValueAtTime(LEVEL, at + RAMP);
629
+ gain.gain.setValueAtTime(LEVEL, at + dur - RAMP);
630
+ gain.gain.linearRampToValueAtTime(0, at + dur);
631
+ };
632
+ const scheduleCycle = (at) => {
633
+ burst(at, 0.4);
634
+ burst(at + 0.6, 0.4);
635
+ return 3;
636
+ };
637
+ const LOOKAHEAD = 0.5;
638
+ const TICK_MS = 150;
639
+ const LEAD = 0.08;
640
+ let stopped = false;
641
+ let timer = null;
642
+ let nextAt = 0;
643
+ const pump = () => {
644
+ while (nextAt < ctx.currentTime + LOOKAHEAD) nextAt += scheduleCycle(nextAt);
645
+ };
646
+ void Promise.resolve(ctx.resume?.()).catch(() => {
647
+ }).finally(() => {
648
+ if (stopped) return;
649
+ nextAt = ctx.currentTime + LEAD;
650
+ pump();
651
+ timer = setInterval(pump, TICK_MS);
652
+ });
653
+ return {
654
+ stop() {
655
+ stopped = true;
656
+ if (timer) clearInterval(timer);
657
+ try {
658
+ osc.stop();
659
+ } catch {
660
+ }
661
+ try {
662
+ gain.disconnect();
663
+ } catch {
664
+ }
665
+ }
666
+ };
667
+ }
668
+ function playCallEndedTone() {
669
+ const ctx = sharedContext();
670
+ if (!ctx) return;
671
+ const gain = ctx.createGain();
672
+ gain.gain.value = 0;
673
+ gain.connect(ctx.destination);
674
+ const osc = ctx.createOscillator();
675
+ osc.type = "sine";
676
+ osc.frequency.value = TONE_HZ;
677
+ osc.connect(gain);
678
+ osc.start();
679
+ const LEVEL = 0.55;
680
+ const RAMP = 8e-3;
681
+ const BEEP = 0.25;
682
+ void Promise.resolve(ctx.resume?.()).catch(() => {
683
+ }).finally(() => {
684
+ const at = ctx.currentTime + 0.02;
685
+ gain.gain.setValueAtTime(0, at);
686
+ gain.gain.linearRampToValueAtTime(LEVEL, at + RAMP);
687
+ gain.gain.setValueAtTime(LEVEL, at + BEEP - RAMP);
688
+ gain.gain.linearRampToValueAtTime(0, at + BEEP);
689
+ const totalMs = (at + BEEP - ctx.currentTime) * 1e3;
690
+ setTimeout(() => {
691
+ try {
692
+ osc.stop();
693
+ } catch {
694
+ }
695
+ try {
696
+ gain.disconnect();
697
+ } catch {
698
+ }
699
+ }, totalMs);
700
+ });
701
+ }
702
+ var DTMF_ROWS_HZ = [697, 770, 852, 941];
703
+ var DTMF_COLS_HZ = [1209, 1336, 1477];
704
+ var DTMF_LAYOUT = ["123", "456", "789", "*0#"];
705
+ function dtmfFrequencies(key) {
706
+ if (key.length !== 1) return null;
707
+ for (let row = 0; row < DTMF_LAYOUT.length; row++) {
708
+ const col = DTMF_LAYOUT[row].indexOf(key);
709
+ if (col !== -1) return [DTMF_ROWS_HZ[row], DTMF_COLS_HZ[col]];
710
+ }
711
+ return null;
712
+ }
713
+ function playKeyTone(key) {
714
+ const hz = dtmfFrequencies(key);
715
+ if (!hz) return;
716
+ const ctx = sharedContext();
717
+ if (!ctx) return;
718
+ const LEVEL = 0.2;
719
+ const RAMP = 6e-3;
720
+ const DURATION = 0.12;
721
+ const LEAD = 0.01;
722
+ const gain = ctx.createGain();
723
+ gain.gain.value = 0;
724
+ gain.connect(ctx.destination);
725
+ const oscillators = hz.map((freq) => {
726
+ const osc = ctx.createOscillator();
727
+ osc.type = "sine";
728
+ osc.frequency.value = freq;
729
+ osc.connect(gain);
730
+ osc.start();
731
+ return osc;
732
+ });
733
+ const fire = () => {
734
+ const at = ctx.currentTime + LEAD;
735
+ gain.gain.setValueAtTime(0, at);
736
+ gain.gain.linearRampToValueAtTime(LEVEL, at + RAMP);
737
+ gain.gain.setValueAtTime(LEVEL, at + DURATION - RAMP);
738
+ gain.gain.linearRampToValueAtTime(0, at + DURATION);
739
+ for (const osc of oscillators) osc.stop(at + DURATION);
740
+ oscillators[0].onended = () => gain.disconnect();
741
+ };
742
+ if (ctx.state === "running") fire();
743
+ else void Promise.resolve(ctx.resume?.()).catch(() => {
744
+ }).finally(fire);
745
+ }
746
+ var ringbackUrl = null;
747
+ function ringbackSource() {
748
+ if (ringbackUrl) return ringbackUrl;
749
+ const sampleRate = 48e3;
750
+ const channels = 2;
751
+ const bytesPerSample = 2;
752
+ const amp = 0.5;
753
+ const toneSec = 1.5;
754
+ const silenceSec = 3;
755
+ const totalFrames = Math.round((toneSec + silenceSec) * sampleRate);
756
+ const toneFrames = Math.round(toneSec * sampleRate);
757
+ const ramp = Math.round(0.02 * sampleRate);
758
+ const blockAlign = channels * bytesPerSample;
759
+ const dataSize = totalFrames * blockAlign;
760
+ const buffer = new ArrayBuffer(44 + dataSize);
761
+ const view = new DataView(buffer);
762
+ const str = (off, s) => {
763
+ for (let i = 0; i < s.length; i++) view.setUint8(off + i, s.charCodeAt(i));
764
+ };
765
+ str(0, "RIFF");
766
+ view.setUint32(4, 36 + dataSize, true);
767
+ str(8, "WAVE");
768
+ str(12, "fmt ");
769
+ view.setUint32(16, 16, true);
770
+ view.setUint16(20, 1, true);
771
+ view.setUint16(22, channels, true);
772
+ view.setUint32(24, sampleRate, true);
773
+ view.setUint32(28, sampleRate * blockAlign, true);
774
+ view.setUint16(32, blockAlign, true);
775
+ view.setUint16(34, bytesPerSample * 8, true);
776
+ str(36, "data");
777
+ view.setUint32(40, dataSize, true);
778
+ for (let i = 0; i < totalFrames; i++) {
779
+ let sample = 0;
780
+ if (i < toneFrames) {
781
+ let env = 1;
782
+ if (i < ramp) env = i / ramp;
783
+ else if (i > toneFrames - ramp) env = Math.max(0, (toneFrames - i) / ramp);
784
+ sample = Math.sin(2 * Math.PI * TONE_HZ * (i / sampleRate)) * amp * env;
785
+ }
786
+ const s16 = Math.round(sample * 32767);
787
+ const off = 44 + i * blockAlign;
788
+ for (let c = 0; c < channels; c++) view.setInt16(off + c * bytesPerSample, s16, true);
789
+ }
790
+ ringbackUrl = URL.createObjectURL(new Blob([buffer], { type: "audio/wav" }));
791
+ return ringbackUrl;
792
+ }
793
+ function playRingback() {
794
+ const el = document.createElement("audio");
795
+ el.src = ringbackSource();
796
+ el.loop = true;
797
+ el.style.display = "none";
798
+ document.body.appendChild(el);
799
+ void el.play().catch(() => {
800
+ });
801
+ return {
802
+ stop() {
803
+ el.pause();
804
+ el.remove();
805
+ }
806
+ };
807
+ }
808
+ var NOT_DIALABLE = /* @__PURE__ */ new Set(["*", "#"]);
809
+ function Dialpad({ outcome = null, onIntent, keyTones = true } = {}) {
568
810
  const softphone = useSoftphone();
569
811
  const state = useSoftphoneState();
570
812
  const [value, setValue] = useState("");
@@ -585,6 +827,7 @@ function Dialpad({ outcome = null, onIntent } = {}) {
585
827
  const append = (key) => {
586
828
  onIntent?.();
587
829
  setValue((v) => v + key);
830
+ if (keyTones) playKeyTone(key);
588
831
  };
589
832
  const backspace = () => {
590
833
  onIntent?.();
@@ -629,22 +872,25 @@ function Dialpad({ outcome = null, onIntent } = {}) {
629
872
  )
630
873
  ] }),
631
874
  /* @__PURE__ */ jsx("p", { className: "cv-dial-outcome", role: "status", part: "error", "data-shown": outcome !== null, children: outcome?.text ?? "" }),
632
- /* @__PURE__ */ jsx("div", { className: "cv-keypad", children: KEYS.map(([key, letters, dialable]) => /* @__PURE__ */ jsxs(
633
- "button",
634
- {
635
- type: "button",
636
- className: "cv-key",
637
- "data-dialable": dialable,
638
- onClick: () => dialable && append(key),
639
- "aria-disabled": !dialable,
640
- title: dialable ? void 0 : "Solo disponible durante una llamada",
641
- children: [
642
- /* @__PURE__ */ jsx("b", { children: key }),
643
- letters ? /* @__PURE__ */ jsx("small", { children: letters }) : null
644
- ]
645
- },
646
- key
647
- )) }),
875
+ /* @__PURE__ */ jsx("div", { className: "cv-keypad", children: KEYPAD_KEYS.map(([key, letters]) => {
876
+ const dialable = !NOT_DIALABLE.has(key);
877
+ return /* @__PURE__ */ jsxs(
878
+ "button",
879
+ {
880
+ type: "button",
881
+ className: "cv-key",
882
+ "data-dialable": dialable,
883
+ onClick: () => dialable && append(key),
884
+ "aria-disabled": !dialable,
885
+ title: dialable ? void 0 : "Solo disponible durante una llamada",
886
+ children: [
887
+ /* @__PURE__ */ jsx("b", { children: key }),
888
+ letters ? /* @__PURE__ */ jsx("small", { children: letters }) : null
889
+ ]
890
+ },
891
+ key
892
+ );
893
+ }) }),
648
894
  /* @__PURE__ */ jsx("div", { className: "cv-dial-foot", children: /* @__PURE__ */ jsx(
649
895
  "button",
650
896
  {
@@ -679,6 +925,125 @@ function elapsedSeconds(since, now) {
679
925
  if (!since) return 0;
680
926
  return Math.floor((now - since.getTime()) / 1e3);
681
927
  }
928
+ function formatAge(totalSeconds) {
929
+ const s = Math.max(0, Math.floor(totalSeconds));
930
+ if (s < 60) return "justo ahora";
931
+ const mm = Math.floor(s / 60);
932
+ if (mm < 60) return `hace ${mm} min`;
933
+ const hh = Math.floor(mm / 60);
934
+ return `hace ${hh} h`;
935
+ }
936
+
937
+ // src/ui/lib/error-text.ts
938
+ var ERROR_TEXT = {
939
+ // ── Engine lifecycle ────────────────────────────────────────────────────────
940
+ // Integrator mistakes rather than user-facing failures, but they must still read as something
941
+ // rather than as a blank panel if one ever reaches the UI.
942
+ "engine/duplicate-instance": {
943
+ title: "Tel\xE9fono duplicado",
944
+ body: "Hay otro tel\xE9fono abierto en esta p\xE1gina. Cierra uno y vuelve a cargar."
945
+ },
946
+ "engine/not-started": { title: "El tel\xE9fono a\xFAn no est\xE1 listo" },
947
+ "engine/destroyed": {
948
+ title: "Tel\xE9fono cerrado",
949
+ body: "Vuelve a cargar la p\xE1gina para usarlo de nuevo."
950
+ },
951
+ // ── Auth ────────────────────────────────────────────────────────────────────
952
+ // Never "token": the user has no idea what one is. What they can act on is the session.
953
+ "auth/token-fetch-failed": {
954
+ title: "Sin conexi\xF3n con el servidor",
955
+ body: "No se ha podido activar el tel\xE9fono. Reint\xE9ntalo en unos segundos."
956
+ },
957
+ "auth/token-rejected": {
958
+ title: "Sesi\xF3n caducada",
959
+ body: "Vuelve a iniciar sesi\xF3n para poder llamar."
960
+ },
961
+ // ── Media ───────────────────────────────────────────────────────────────────
962
+ // Verbatim from MicPermissionGate, which already says these well and says them first.
963
+ //
964
+ // NEVER ACTUALLY SHOWN via this table — these four codes (through `media/playback-blocked`, below)
965
+ // are never raised as a `SoftphoneError` at all. See the fuller comment on each in `core/errors.ts`
966
+ // for why. Copy kept here anyway so the catalogue and this file stay the same shape as every other
967
+ // code — `error-text.test.ts` would fail the build the moment they drifted.
968
+ "media/permission-denied": {
969
+ title: "Micr\xF3fono bloqueado",
970
+ body: "Perm\xEDtelo en tu navegador para poder llamar."
971
+ },
972
+ "media/no-input-device": {
973
+ title: "Sin micr\xF3fono",
974
+ body: "Conecta un micr\xF3fono y vuelve a intentarlo."
975
+ },
976
+ "media/webrtc-unsupported": {
977
+ title: "Llamadas no disponibles",
978
+ body: "Este navegador no admite llamadas. Prueba con Chrome, Edge o Firefox."
979
+ },
980
+ "media/playback-blocked": { title: "Toca la pantalla para activar el sonido" },
981
+ // ── Network / signalling ────────────────────────────────────────────────────
982
+ // "Centralita" rather than "SIP" or "registro": the user knows what a phone system is.
983
+ "net/registration-failed": {
984
+ title: "No se pudo conectar",
985
+ body: "No hemos podido conectar con la centralita. Reintenta en unos segundos."
986
+ },
987
+ "net/transport-failed": {
988
+ title: "Conexi\xF3n perdida",
989
+ body: "Se ha perdido la conexi\xF3n con la centralita."
990
+ },
991
+ // NEVER ACTUALLY SHOWN either — CallScreen's own "Reconectando…" already covers this, off the
992
+ // call's `reconnecting` state rather than this channel. See the comment in `core/errors.ts`.
993
+ "net/ice-failed": { title: "Recuperando el audio\u2026" },
994
+ // ── Call ────────────────────────────────────────────────────────────────────
995
+ /*
996
+ THE VOICE RULE FOR THIS BLOCK: say what HAPPENED, not what it is called.
997
+
998
+ These were status labels — "Llamada perdida", "Llamada rechazada", "Sin respuesta". Each is a
999
+ name for a category rather than an account of an event, which is why they read as a database enum
1000
+ someone forgot to translate into human. Worse, a label hides the ACTOR: "Llamada rechazada" never
1001
+ says who rejected what, so the user reading it after declining a call and the user reading it
1002
+ after being declined see the same three words.
1003
+
1004
+ So each one is a short sentence about what the other person, or the phone, did — the form
1005
+ `call/busy` already had, and the reason it was the only one here that read clearly.
1006
+
1007
+ Impersonal third-person plural ("contestaron", "colgaron") throughout: the user does not know who
1008
+ is at the other end, and it is what Spanish uses when the subject is unknown. Mixing it with the
1009
+ singular across neighbouring lines reads as carelessness.
1010
+
1011
+ LENGTH IS A HARD CONSTRAINT, not a preference. `.cv-dial-outcome` is one line at a fixed
1012
+ `1.15rem`, and the keypad below it is `flex: 1` — a title that wraps resizes every key and shifts
1013
+ the grid under a finger already reaching for it. `error-text.test.ts` caps this at 48 characters.
1014
+ */
1015
+ "call/not-answerable": { title: "Esa llamada ya ha terminado" },
1016
+ /*
1017
+ NOT "Comunicando", which is what this said and what sent us looking. SIP 486 is both "the line is
1018
+ engaged" AND what almost every endpoint sends when a person presses decline — including our own
1019
+ `reject()`. One code, two events, no way to tell them apart, so the copy has to hold both rather
1020
+ than assert the less likely one. `call/rejected` can be definite: an endpoint sending 603 Decline
1021
+ means exactly that.
1022
+ */
1023
+ "call/busy": { title: "No contestaron o la l\xEDnea estaba ocupada" },
1024
+ "call/no-answer": { title: "No contestaron" },
1025
+ "call/rejected": { title: "No aceptaron tu llamada" },
1026
+ /*
1027
+ The legacy CRM said "La persona que llamaba colgó antes de que pudieras contestar." — the right
1028
+ fact, at three times the width of the line that has to hold it. This keeps the fact and drops the
1029
+ words: the user needs to know the call was FOR them and that nobody is waiting on the line.
1030
+ "Llamada perdida" alone tested badly for exactly the label reason above — it names the category
1031
+ without saying anyone rang.
1032
+ */
1033
+ "call/missed": { title: "Colgaron antes de que pudieras contestar" },
1034
+ // "No se pudo completar la llamada" was our own "something went wrong": true of every failure and
1035
+ // useful for none. The number is what the user can actually act on.
1036
+ "call/failed": { title: "No se pudo conectar con ese n\xFAmero" },
1037
+ // The load-bearing half is the SECOND sentence: the original call is still up, and a user who
1038
+ // thinks they dropped it will hang up on a client to "fix" it.
1039
+ "call/transfer-failed": { title: "No se pudo transferir. Sigues en la llamada" },
1040
+ "call/unsupported": { title: "Esta funci\xF3n no est\xE1 disponible" },
1041
+ // ── Provider ────────────────────────────────────────────────────────────────
1042
+ "provider/provision-failed": {
1043
+ title: "No se pudo activar el tel\xE9fono",
1044
+ body: "Si el problema contin\xFAa, contacta con soporte."
1045
+ }
1046
+ };
682
1047
  function useTicker(enabled, intervalMs = 1e3) {
683
1048
  const [now, setNow] = useState(() => Date.now());
684
1049
  useEffect(() => {
@@ -760,65 +1125,262 @@ function DevicePickers() {
760
1125
  )
761
1126
  ] });
762
1127
  }
763
- function Control({
764
- icon: Icon3,
765
- label,
766
- pressed = false,
767
- tone,
768
- onClick
1128
+ function Control({
1129
+ icon: Icon3,
1130
+ label,
1131
+ pressed = false,
1132
+ tone,
1133
+ onClick,
1134
+ disabled = false,
1135
+ title
1136
+ }) {
1137
+ return /* @__PURE__ */ jsxs("div", { className: "cv-ctrl-item", children: [
1138
+ /* @__PURE__ */ jsx(
1139
+ "button",
1140
+ {
1141
+ type: "button",
1142
+ className: "cv-ctrl",
1143
+ "data-tone": pressed ? tone : void 0,
1144
+ "aria-pressed": pressed,
1145
+ "aria-label": label,
1146
+ onClick,
1147
+ disabled,
1148
+ title,
1149
+ children: /* @__PURE__ */ jsx(Icon3, { size: 19 })
1150
+ }
1151
+ ),
1152
+ /* @__PURE__ */ jsx("span", { className: "cv-ctrl-label", "aria-hidden": true, children: label })
1153
+ ] });
1154
+ }
1155
+ var STATUS_LABEL = {
1156
+ ringing: "Llamada entrante",
1157
+ dialing: "Llamando\u2026",
1158
+ early: "Llamando\u2026",
1159
+ answering: "Conectando\u2026",
1160
+ reconnecting: "Reconectando\u2026",
1161
+ held: "En espera"
1162
+ };
1163
+ var CONSULT_FAIL_CODE = {
1164
+ busy: "call/busy",
1165
+ "no-answer": "call/no-answer",
1166
+ rejected: "call/rejected",
1167
+ failed: "call/failed"
1168
+ };
1169
+ function consultFailText(reason) {
1170
+ return ERROR_TEXT[CONSULT_FAIL_CODE[reason] ?? "call/transfer-failed"].title;
1171
+ }
1172
+ var MIN_CONSULT_HOLD_SECONDS = 10;
1173
+ function ConsultScreen({
1174
+ mainName,
1175
+ consultView,
1176
+ now,
1177
+ onReturn,
1178
+ onComplete,
1179
+ onHangupAll
1180
+ }) {
1181
+ const s = consultView.state;
1182
+ const answered = s === "active";
1183
+ const name = consultView.remote.displayName;
1184
+ const number = consultView.remote.number;
1185
+ const showNumber = number !== name;
1186
+ const heldSeconds = elapsedSeconds(consultView.answeredAt, now);
1187
+ const [rawM = "0", ss = "00"] = formatDuration(heldSeconds).split(":");
1188
+ const mm = rawM.padStart(2, "0");
1189
+ const canComplete = heldSeconds >= MIN_CONSULT_HOLD_SECONDS;
1190
+ return /* @__PURE__ */ jsxs("div", { className: "cv-view", children: [
1191
+ /* @__PURE__ */ jsxs("div", { className: "cv-call", children: [
1192
+ /* @__PURE__ */ jsxs("button", { type: "button", className: "cv-consult-hold-banner", onClick: onReturn, children: [
1193
+ /* @__PURE__ */ jsx(Pause, { size: 13, "aria-hidden": true }),
1194
+ mainName,
1195
+ " en espera \xB7 Volver"
1196
+ ] }),
1197
+ /* @__PURE__ */ jsxs("div", { className: "cv-avatar-wrap", children: [
1198
+ /* @__PURE__ */ jsx("div", { className: "cv-avatar", "aria-hidden": true, children: initials(name) }),
1199
+ answered ? /* @__PURE__ */ jsx("span", { className: "cv-avatar-dot", "aria-hidden": true }) : null
1200
+ ] }),
1201
+ /* @__PURE__ */ jsx("div", { className: "cv-call-name", children: name }),
1202
+ showNumber ? /* @__PURE__ */ jsx("div", { className: "cv-call-number", children: number }) : null,
1203
+ answered ? /* @__PURE__ */ jsxs("div", { className: "cv-timer", "aria-label": "Duraci\xF3n de la llamada", children: [
1204
+ /* @__PURE__ */ jsx("b", { children: mm }),
1205
+ /* @__PURE__ */ jsx("i", { children: ":" }),
1206
+ /* @__PURE__ */ jsx("b", { children: ss })
1207
+ ] }) : /* @__PURE__ */ jsx("div", { className: "cv-call-status", children: STATUS_LABEL[s] ?? "" })
1208
+ ] }),
1209
+ /* @__PURE__ */ jsxs("div", { className: "cv-controls", children: [
1210
+ /* @__PURE__ */ jsxs("div", { className: "cv-transfer-actions", children: [
1211
+ answered ? /* @__PURE__ */ jsx("button", { className: "cv-btn cv-btn--accent", onClick: onComplete, disabled: !canComplete, children: "Completar" }) : null,
1212
+ /* @__PURE__ */ jsx("button", { className: "cv-btn", onClick: onReturn, children: "Cancelar" })
1213
+ ] }),
1214
+ /* @__PURE__ */ jsx("div", { className: "cv-controls-primary", children: /* @__PURE__ */ jsxs("div", { className: "cv-ctrl-item", children: [
1215
+ /* @__PURE__ */ jsx(
1216
+ "button",
1217
+ {
1218
+ type: "button",
1219
+ className: "cv-round cv-round--hangup",
1220
+ onClick: onHangupAll,
1221
+ "aria-label": "Colgar todo",
1222
+ children: /* @__PURE__ */ jsx(PhoneOff, { size: 26 })
1223
+ }
1224
+ ),
1225
+ /* @__PURE__ */ jsx("span", { className: "cv-ctrl-label", "aria-hidden": true, children: "Colgar todo" })
1226
+ ] }) })
1227
+ ] })
1228
+ ] });
1229
+ }
1230
+ var PRESENCE_LABEL = {
1231
+ disponible: "Disponible",
1232
+ en_llamada: "En llamada",
1233
+ desconectado: "Desconectado"
1234
+ };
1235
+ var PRESENCE_RANK = {
1236
+ disponible: 0,
1237
+ en_llamada: 1,
1238
+ desconectado: 2
1239
+ };
1240
+ function filterAgents(agents, query) {
1241
+ const q = query.trim().toLowerCase();
1242
+ return agents.filter((a) => !q || (a.name ?? "").toLowerCase().includes(q) || a.extension.includes(q)).sort(
1243
+ (a, b) => PRESENCE_RANK[a.state] - PRESENCE_RANK[b.state] || (a.name ?? a.extension).localeCompare(b.name ?? b.extension)
1244
+ );
1245
+ }
1246
+ function truncateForButton(text, max = 16) {
1247
+ return text.length > max ? `${text.slice(0, max)}\u2026` : text;
1248
+ }
1249
+ function TransferPicker({
1250
+ agents,
1251
+ myUserId,
1252
+ query,
1253
+ onSelect
769
1254
  }) {
770
- return /* @__PURE__ */ jsxs("div", { className: "cv-ctrl-item", children: [
771
- /* @__PURE__ */ jsx(
772
- "button",
773
- {
774
- type: "button",
775
- className: "cv-ctrl",
776
- "data-tone": pressed ? tone : void 0,
777
- "aria-pressed": pressed,
778
- "aria-label": label,
779
- onClick,
780
- children: /* @__PURE__ */ jsx(Icon3, { size: 19 })
781
- }
782
- ),
783
- /* @__PURE__ */ jsx("span", { className: "cv-ctrl-label", "aria-hidden": true, children: label })
1255
+ const others = agents.filter((a) => a.userId !== myUserId);
1256
+ if (others.length === 0) return null;
1257
+ const filtered = filterAgents(others, query);
1258
+ return /* @__PURE__ */ jsxs("div", { className: "cv-presence", children: [
1259
+ /* @__PURE__ */ jsx("div", { className: "cv-presence-list", role: "listbox", "aria-label": "Compa\xF1eros", children: filtered.length === 0 ? /* @__PURE__ */ jsx("p", { className: "cv-presence-empty", children: "Sin coincidencias" }) : filtered.map((agent) => {
1260
+ const isBusy = agent.state === "en_llamada";
1261
+ return /* @__PURE__ */ jsxs(
1262
+ "button",
1263
+ {
1264
+ type: "button",
1265
+ role: "option",
1266
+ className: "cv-presence-item",
1267
+ disabled: isBusy,
1268
+ onClick: () => onSelect(agent.extension),
1269
+ children: [
1270
+ /* @__PURE__ */ jsx("span", { className: "cv-presence-dot", "data-state": agent.state, "aria-hidden": true }),
1271
+ /* @__PURE__ */ jsx("span", { className: "cv-presence-name", children: agent.name ?? agent.extension }),
1272
+ /* @__PURE__ */ jsxs("span", { className: "cv-presence-meta", children: [
1273
+ agent.extension,
1274
+ " \xB7 ",
1275
+ PRESENCE_LABEL[agent.state]
1276
+ ] })
1277
+ ]
1278
+ },
1279
+ agent.userId
1280
+ );
1281
+ }) }),
1282
+ /* @__PURE__ */ jsx("p", { className: "cv-presence-legend", children: "Desconectado: sin conexi\xF3n en el softphone web. Puede atender desde otro dispositivo." })
784
1283
  ] });
785
1284
  }
786
- var STATUS_LABEL = {
787
- ringing: "Llamada entrante",
788
- dialing: "Llamando\u2026",
789
- early: "Llamando\u2026",
790
- answering: "Conectando\u2026",
791
- reconnecting: "Reconectando\u2026",
792
- held: "En espera"
793
- };
794
1285
  function CallScreen({
795
1286
  call,
796
- incomingCallAction
1287
+ incomingCallAction,
1288
+ keyTones = true
797
1289
  }) {
798
1290
  const view = useCall(call);
799
- const [showTransfer, setShowTransfer] = useState(false);
1291
+ const softphone = useSoftphone();
1292
+ const [panel, setPanel] = useState(null);
1293
+ const showTransfer = panel === "transfer";
1294
+ const showKeypad = panel === "keypad";
1295
+ const showDevices = panel === "devices";
1296
+ const togglePanel = (p) => setPanel((cur) => cur === p ? null : p);
800
1297
  const [transferTo, setTransferTo] = useState("");
801
- const [showDevices, setShowDevices] = useState(false);
1298
+ const [consultCall, setConsultCall] = useState(null);
1299
+ const [consultOutcome, setConsultOutcome] = useState(null);
1300
+ const [myUserId, setMyUserId] = useState(null);
1301
+ const consultView = useCall(consultCall);
1302
+ const consultIntentRef = useRef(null);
802
1303
  const controlsRef = useRef(null);
803
1304
  const now = useTicker(view?.state === "active");
1305
+ const consultNow = useTicker(consultView?.state === "active");
804
1306
  const isConnected = view ? view.state === "active" || view.state === "held" || view.state === "reconnecting" : false;
1307
+ const transferPickerActive = isConnected && showTransfer && !consultCall;
1308
+ const presence = usePresence(transferPickerActive);
1309
+ useEffect(() => {
1310
+ const listAgents = softphone.sources?.listAgents;
1311
+ if (!listAgents) return;
1312
+ let cancelled = false;
1313
+ void listAgents().then((d) => !cancelled && setMyUserId(d.me?.userId ?? null)).catch(() => {
1314
+ });
1315
+ return () => {
1316
+ cancelled = true;
1317
+ };
1318
+ }, [softphone]);
1319
+ useEffect(() => {
1320
+ if (!consultCall) return;
1321
+ return consultCall.on("ended" /* Ended */, ({ reason }) => {
1322
+ const intent = consultIntentRef.current;
1323
+ consultIntentRef.current = null;
1324
+ setConsultCall(null);
1325
+ if (!intent) {
1326
+ setConsultOutcome(reason);
1327
+ if (call.state !== "ended") void call.unhold();
1328
+ }
1329
+ });
1330
+ }, [consultCall, call]);
1331
+ const startConsult = async (target) => {
1332
+ const t = (target ?? transferTo).trim();
1333
+ if (!t) return;
1334
+ setConsultOutcome(null);
1335
+ setConsultCall(await call.startConsult(t));
1336
+ };
1337
+ const selectPresenceAgent = (extension) => {
1338
+ setTransferTo(extension);
1339
+ void startConsult(extension);
1340
+ };
1341
+ const [quickTransfer, setQuickTransfer] = useState(false);
1342
+ const selectPresenceAgentAuto = (extension) => {
1343
+ void call.transfer(extension);
1344
+ };
1345
+ const completeTransfer = async () => {
1346
+ if (!consultCall) return;
1347
+ const target = transferTo.trim();
1348
+ consultIntentRef.current = "complete";
1349
+ await consultCall.hangup();
1350
+ await call.transfer(target);
1351
+ };
1352
+ const cancelConsult = async () => {
1353
+ consultIntentRef.current = "cancel";
1354
+ await call.cancelConsult();
1355
+ };
805
1356
  useEffect(() => {
806
1357
  if (!showDevices) return;
807
1358
  const onKey = (e) => {
808
- if (e.key === "Escape") setShowDevices(false);
1359
+ if (e.key === "Escape") setPanel(null);
1360
+ };
1361
+ document.addEventListener("keydown", onKey);
1362
+ return () => document.removeEventListener("keydown", onKey);
1363
+ }, [showDevices]);
1364
+ useEffect(() => {
1365
+ if (!showKeypad) return;
1366
+ const onKey = (e) => {
1367
+ if (e.key === "Escape") setPanel(null);
809
1368
  };
810
1369
  document.addEventListener("keydown", onKey);
811
1370
  if (!isConnected) return () => document.removeEventListener("keydown", onKey);
812
1371
  const root = controlsRef.current?.getRootNode() ?? document;
813
1372
  const onDown = (e) => {
814
- if (!controlsRef.current?.contains(e.target)) setShowDevices(false);
1373
+ if (!controlsRef.current?.contains(e.target)) setPanel(null);
815
1374
  };
816
1375
  root.addEventListener("pointerdown", onDown);
817
1376
  return () => {
818
1377
  document.removeEventListener("keydown", onKey);
819
1378
  root.removeEventListener("pointerdown", onDown);
820
1379
  };
821
- }, [showDevices, isConnected]);
1380
+ }, [showKeypad, isConnected]);
1381
+ useEffect(() => {
1382
+ if (view && view.state !== "active") setPanel((cur) => cur === "keypad" ? null : cur);
1383
+ }, [view?.state]);
822
1384
  if (!view || view.state === "ended") return null;
823
1385
  const s = view.state;
824
1386
  const isRinging = s === "ringing";
@@ -826,14 +1388,27 @@ function CallScreen({
826
1388
  const name = view.remote.displayName;
827
1389
  const number = view.remote.number;
828
1390
  const showNumber = number !== name;
1391
+ if (consultCall && consultView) {
1392
+ return /* @__PURE__ */ jsx(
1393
+ ConsultScreen,
1394
+ {
1395
+ mainName: name,
1396
+ consultView,
1397
+ now: consultNow,
1398
+ onReturn: () => void cancelConsult(),
1399
+ onComplete: () => void completeTransfer(),
1400
+ onHangupAll: () => void call.hangup()
1401
+ }
1402
+ );
1403
+ }
829
1404
  const incomingAction = isRinging && view.remote.contact && incomingCallAction ? incomingCallAction : null;
830
1405
  const IncomingActionIcon = incomingAction?.icon ?? ExternalLink;
831
1406
  const [rawM = "0", ss = "00"] = formatDuration(elapsedSeconds(view.answeredAt, now)).split(":");
832
1407
  const mm = rawM.padStart(2, "0");
833
1408
  return /* @__PURE__ */ jsxs("div", { className: "cv-view", children: [
834
- /* @__PURE__ */ jsx("div", { className: "cv-call", children: !inCall && showDevices ? /* @__PURE__ */ jsxs("div", { className: "cv-call-devices-inline", children: [
1409
+ /* @__PURE__ */ jsx("div", { className: "cv-call", children: showDevices ? /* @__PURE__ */ jsxs("div", { className: "cv-call-devices-inline", children: [
835
1410
  /* @__PURE__ */ jsxs("div", { className: "cv-call-devices-header", children: [
836
- /* @__PURE__ */ jsxs("button", { type: "button", className: "cv-call-devices-chip", onClick: () => setShowDevices(false), children: [
1411
+ /* @__PURE__ */ jsxs("button", { type: "button", className: "cv-call-devices-chip", onClick: () => setPanel(null), children: [
837
1412
  /* @__PURE__ */ jsx(ArrowLeft, { size: 13, "aria-hidden": true }),
838
1413
  "Volver"
839
1414
  ] }),
@@ -841,10 +1416,82 @@ function CallScreen({
841
1416
  ] }),
842
1417
  /* @__PURE__ */ jsxs("p", { className: "cv-call-devices-context", children: [
843
1418
  showNumber ? `${number} \xB7 ` : "",
844
- STATUS_LABEL[s] ?? ""
1419
+ s === "active" ? `${mm}:${ss}` : STATUS_LABEL[s] ?? ""
845
1420
  ] }),
846
1421
  /* @__PURE__ */ jsx("div", { className: "cv-settings-section-body", children: /* @__PURE__ */ jsx(DevicePickers, {}) })
847
- ] }) : /* @__PURE__ */ jsxs(Fragment, { children: [
1422
+ ] }) : transferPickerActive ? (
1423
+ /*
1424
+ Found live (2026-09-29): the agent list a moment ago picked between showing ONE agent row
1425
+ or forcing a scroll — `.cv-presence-list`'s own `max-height` had already been trimmed twice
1426
+ (T7, then T3) chasing that, and both times the room it was chasing lived somewhere else.
1427
+ The full avatar/name/giant-timer block above stayed on screen, unconditionally, the WHOLE
1428
+ time the picker was open — exactly the anti-pattern `.cv-call-devices-inline` (above) was
1429
+ already built to avoid for the pre-connect device picker: "an inline swap, not an overlay,
1430
+ so it gets the full box, not a leftover sliver." The picker never got that same treatment.
1431
+ Measured live (a temporary harness, `playground/react/picker-harness.*`, real Chromium at
1432
+ this SDK's own real embed footprint, 310×520): the avatar+name+timer block alone cost
1433
+ ~150px of `.cv-call`'s ~340px of usable height — more than the entire agent list, the mode
1434
+ toggle, and the free-text row combined. Reusing `.cv-call-devices-inline`'s OWN "who's
1435
+ calling doesn't disappear" compact context line (below) instead of the full block gives
1436
+ that ~150px back to the one thing this screen is actually for right now: the list.
1437
+ */
1438
+ /* @__PURE__ */ jsxs("div", { className: "cv-transfer", children: [
1439
+ /* @__PURE__ */ jsxs("div", { className: "cv-transfer-context", children: [
1440
+ /* @__PURE__ */ jsx("span", { className: "cv-call-devices-title", children: name }),
1441
+ /* @__PURE__ */ jsxs("p", { className: "cv-call-devices-context", children: [
1442
+ showNumber ? `${number} \xB7 ` : "",
1443
+ s === "active" ? `${mm}:${ss}` : STATUS_LABEL[s] ?? ""
1444
+ ] })
1445
+ ] }),
1446
+ consultOutcome ? /* @__PURE__ */ jsx("p", { className: "cv-dial-outcome", role: "status", "data-shown": "true", children: consultFailText(consultOutcome) }) : null,
1447
+ /* @__PURE__ */ jsxs("div", { className: "cv-transfer-row", children: [
1448
+ /* @__PURE__ */ jsx(
1449
+ "input",
1450
+ {
1451
+ className: "cv-search",
1452
+ value: transferTo,
1453
+ onChange: (e) => setTransferTo(e.target.value),
1454
+ placeholder: "Nombre, ext. o n\xFAmero",
1455
+ "aria-label": "Transferir a",
1456
+ autoFocus: true
1457
+ }
1458
+ ),
1459
+ /* @__PURE__ */ jsx(
1460
+ "button",
1461
+ {
1462
+ className: "cv-btn cv-btn--accent",
1463
+ disabled: !transferTo.trim(),
1464
+ onClick: () => void startConsult(),
1465
+ children: transferTo.trim() ? `Llamar a ${truncateForButton(transferTo.trim())}` : "Llamar"
1466
+ }
1467
+ )
1468
+ ] }),
1469
+ /* @__PURE__ */ jsx("div", { className: "cv-segmented", role: "tablist", "aria-label": "Modo de transferencia", children: [
1470
+ { key: "consult", label: "Hablar primero" },
1471
+ { key: "auto", label: "Transferir directo" }
1472
+ ].map(({ key, label }) => /* @__PURE__ */ jsx(
1473
+ "button",
1474
+ {
1475
+ type: "button",
1476
+ role: "tab",
1477
+ "aria-selected": quickTransfer === (key === "auto"),
1478
+ className: "cv-segmented-btn",
1479
+ onClick: () => setQuickTransfer(key === "auto"),
1480
+ children: label
1481
+ },
1482
+ key
1483
+ )) }),
1484
+ /* @__PURE__ */ jsx(
1485
+ TransferPicker,
1486
+ {
1487
+ agents: presence,
1488
+ myUserId,
1489
+ query: transferTo,
1490
+ onSelect: quickTransfer ? selectPresenceAgentAuto : selectPresenceAgent
1491
+ }
1492
+ )
1493
+ ] })
1494
+ ) : /* @__PURE__ */ jsxs(Fragment, { children: [
848
1495
  /* @__PURE__ */ jsxs("div", { className: "cv-avatar-wrap", children: [
849
1496
  isRinging ? /* @__PURE__ */ jsx("span", { className: "cv-avatar-ping", "aria-hidden": true }) : null,
850
1497
  /* @__PURE__ */ jsx("div", { className: "cv-avatar", "aria-hidden": true, children: initials(name) }),
@@ -857,7 +1504,7 @@ function CallScreen({
857
1504
  /* @__PURE__ */ jsx("i", { children: ":" }),
858
1505
  /* @__PURE__ */ jsx("b", { children: ss })
859
1506
  ] }) : /* @__PURE__ */ jsx("div", { className: "cv-call-status", children: s === "held" ? /* @__PURE__ */ jsx("span", { className: "cv-badge-hold", children: STATUS_LABEL[s] }) : STATUS_LABEL[s] ?? "" }),
860
- !inCall ? /* @__PURE__ */ jsxs("div", { className: "cv-call-chips", children: [
1507
+ /* @__PURE__ */ jsxs("div", { className: "cv-call-chips", children: [
861
1508
  incomingAction ? /* @__PURE__ */ jsxs(
862
1509
  "button",
863
1510
  {
@@ -870,36 +1517,11 @@ function CallScreen({
870
1517
  ]
871
1518
  }
872
1519
  ) : null,
873
- /* @__PURE__ */ jsxs("button", { type: "button", className: "cv-call-devices-chip", onClick: () => setShowDevices(true), children: [
1520
+ /* @__PURE__ */ jsxs("button", { type: "button", className: "cv-call-devices-chip", onClick: () => setPanel("devices"), children: [
874
1521
  /* @__PURE__ */ jsx(Headphones, { size: 13, "aria-hidden": true }),
875
1522
  "Dispositivos"
876
1523
  ] })
877
- ] }) : null,
878
- inCall && showTransfer ? /* @__PURE__ */ jsxs("div", { className: "cv-transfer", children: [
879
- /* @__PURE__ */ jsx(
880
- "input",
881
- {
882
- className: "cv-search",
883
- value: transferTo,
884
- onChange: (e) => setTransferTo(e.target.value),
885
- placeholder: "Extensi\xF3n o n\xFAmero",
886
- "aria-label": "Transferir a",
887
- autoFocus: true
888
- }
889
- ),
890
- /* @__PURE__ */ jsx(
891
- "button",
892
- {
893
- className: "cv-btn cv-btn--accent",
894
- disabled: !transferTo.trim(),
895
- onClick: () => {
896
- void call.transfer(transferTo.trim());
897
- setShowTransfer(false);
898
- },
899
- children: "Transferir"
900
- }
901
- )
902
- ] }) : null
1524
+ ] })
903
1525
  ] }) }),
904
1526
  /* @__PURE__ */ jsx("div", { className: "cv-controls", ref: controlsRef, children: isRinging ? /* @__PURE__ */ jsxs("div", { className: "cv-controls-primary", children: [
905
1527
  /* @__PURE__ */ jsxs("div", { className: "cv-ctrl-item", children: [
@@ -958,17 +1580,19 @@ function CallScreen({
958
1580
  label: "Transferir",
959
1581
  pressed: showTransfer,
960
1582
  tone: "accent",
961
- onClick: () => setShowTransfer((v) => !v)
1583
+ onClick: () => togglePanel("transfer")
962
1584
  }
963
1585
  ),
964
1586
  /* @__PURE__ */ jsx(
965
1587
  Control,
966
1588
  {
967
- icon: Headphones,
968
- label: "Dispositivos",
969
- pressed: showDevices,
1589
+ icon: Grid3x3,
1590
+ label: "Teclado",
1591
+ pressed: showKeypad,
970
1592
  tone: "accent",
971
- onClick: () => setShowDevices((v) => !v)
1593
+ disabled: s !== "active",
1594
+ title: s === "held" ? "Reanuda la llamada para usar el teclado" : s === "reconnecting" ? "No disponible mientras se reconecta" : void 0,
1595
+ onClick: () => togglePanel("keypad")
972
1596
  }
973
1597
  )
974
1598
  ] }) : null,
@@ -986,7 +1610,22 @@ function CallScreen({
986
1610
  ),
987
1611
  /* @__PURE__ */ jsx("span", { className: "cv-ctrl-label", "aria-hidden": true, children: "Colgar" })
988
1612
  ] }) }),
989
- inCall && showDevices ? /* @__PURE__ */ jsx("div", { className: "cv-device-popover cv-device-popover--above", role: "dialog", "aria-label": "Dispositivos de audio", children: /* @__PURE__ */ jsx(DevicePickers, {}) }) : null
1613
+ inCall && showKeypad ? /* @__PURE__ */ jsx("div", { className: "cv-keypad-popover cv-keypad-popover--above", role: "dialog", "aria-label": "Teclado", children: KEYPAD_KEYS.map(([key, letters]) => /* @__PURE__ */ jsxs(
1614
+ "button",
1615
+ {
1616
+ type: "button",
1617
+ className: "cv-key",
1618
+ onClick: () => {
1619
+ call.sendDigits(key);
1620
+ if (keyTones) playKeyTone(key);
1621
+ },
1622
+ children: [
1623
+ /* @__PURE__ */ jsx("b", { children: key }),
1624
+ letters ? /* @__PURE__ */ jsx("small", { children: letters }) : null
1625
+ ]
1626
+ },
1627
+ key
1628
+ )) }) : null
990
1629
  ] }) })
991
1630
  ] });
992
1631
  }
@@ -1653,159 +2292,48 @@ function MicPrompt({
1653
2292
  }
1654
2293
  if (state === "denied") {
1655
2294
  const guide = unblockGuide();
1656
- return /* @__PURE__ */ jsxs("div", { className: "cv-gate", "data-state": state, "data-tone": "danger", children: [
1657
- /* @__PURE__ */ jsx("span", { className: "cv-gate-mark", children: /* @__PURE__ */ jsx(MicOff, { size: 22 }) }),
1658
- /* @__PURE__ */ jsx("h3", { className: "cv-gate-title", children: "Micr\xF3fono bloqueado" }),
1659
- /* @__PURE__ */ jsx("p", { children: "Perm\xEDtelo en tu navegador para poder llamar." }),
1660
- /* @__PURE__ */ jsxs("details", { className: "cv-help", children: [
1661
- /* @__PURE__ */ jsxs("summary", { children: [
1662
- /* @__PURE__ */ jsx(CircleHelp, { size: 13, "aria-hidden": true }),
1663
- "C\xF3mo permitirlo"
1664
- ] }),
1665
- /* @__PURE__ */ jsx("ol", { children: guide.steps.map((step) => /* @__PURE__ */ jsx("li", { children: step }, step)) }),
1666
- guide.url ? /* @__PURE__ */ jsxs("a", { href: guide.url, target: "_blank", rel: "noreferrer noopener", children: [
1667
- "Gu\xEDa del navegador",
1668
- /* @__PURE__ */ jsx(ExternalLink, { size: 11, "aria-hidden": true })
1669
- ] }) : null
1670
- ] })
1671
- ] });
1672
- }
1673
- if (state === "warming") {
1674
- return /* @__PURE__ */ jsxs("div", { className: "cv-gate", "data-state": state, children: [
1675
- /* @__PURE__ */ jsx("span", { className: "cv-gate-mark", children: /* @__PURE__ */ jsx(Mic, { size: 22 }) }),
1676
- /* @__PURE__ */ jsx("h3", { className: "cv-gate-title", children: "Permite el micr\xF3fono" }),
1677
- /* @__PURE__ */ jsx("p", { children: "Acepta el aviso que muestra el navegador para continuar." })
1678
- ] });
1679
- }
1680
- if (state === "checking") {
1681
- return /* @__PURE__ */ jsxs("div", { className: "cv-gate", "data-state": state, children: [
1682
- /* @__PURE__ */ jsx("span", { className: "cv-gate-mark", children: /* @__PURE__ */ jsx(Mic, { size: 22 }) }),
1683
- /* @__PURE__ */ jsx("p", { children: "Preparando el micr\xF3fono\u2026" })
1684
- ] });
1685
- }
1686
- return /* @__PURE__ */ jsxs("div", { className: "cv-gate", "data-state": state, children: [
1687
- /* @__PURE__ */ jsx("span", { className: "cv-gate-mark", children: /* @__PURE__ */ jsx(Mic, { size: 22 }) }),
1688
- /* @__PURE__ */ jsx("h3", { className: "cv-gate-title", children: "Permite el micr\xF3fono" }),
1689
- /* @__PURE__ */ jsx("p", { children: "Lo necesitamos para que puedas hablar durante las llamadas." }),
1690
- /* @__PURE__ */ jsx("button", { className: "cv-btn cv-btn--accent", onClick: onRequest, children: "Permitir micr\xF3fono" })
1691
- ] });
1692
- }
1693
- function MicPermissionGate({ children }) {
1694
- const { state, request } = useMicPermission();
1695
- if (state === "ready") return /* @__PURE__ */ jsx(Fragment, { children });
1696
- return /* @__PURE__ */ jsx("div", { className: "cv-softphone", children: /* @__PURE__ */ jsx(MicPrompt, { state, onRequest: () => void request() }) });
1697
- }
1698
-
1699
- // src/ui/lib/error-text.ts
1700
- var ERROR_TEXT = {
1701
- // ── Engine lifecycle ────────────────────────────────────────────────────────
1702
- // Integrator mistakes rather than user-facing failures, but they must still read as something
1703
- // rather than as a blank panel if one ever reaches the UI.
1704
- "engine/duplicate-instance": {
1705
- title: "Tel\xE9fono duplicado",
1706
- body: "Hay otro tel\xE9fono abierto en esta p\xE1gina. Cierra uno y vuelve a cargar."
1707
- },
1708
- "engine/not-started": { title: "El tel\xE9fono a\xFAn no est\xE1 listo" },
1709
- "engine/destroyed": {
1710
- title: "Tel\xE9fono cerrado",
1711
- body: "Vuelve a cargar la p\xE1gina para usarlo de nuevo."
1712
- },
1713
- // ── Auth ────────────────────────────────────────────────────────────────────
1714
- // Never "token": the user has no idea what one is. What they can act on is the session.
1715
- "auth/token-fetch-failed": {
1716
- title: "Sin conexi\xF3n con el servidor",
1717
- body: "No se ha podido activar el tel\xE9fono. Reint\xE9ntalo en unos segundos."
1718
- },
1719
- "auth/token-rejected": {
1720
- title: "Sesi\xF3n caducada",
1721
- body: "Vuelve a iniciar sesi\xF3n para poder llamar."
1722
- },
1723
- // ── Media ───────────────────────────────────────────────────────────────────
1724
- // Verbatim from MicPermissionGate, which already says these well and says them first.
1725
- //
1726
- // NEVER ACTUALLY SHOWN via this table — these four codes (through `media/playback-blocked`, below)
1727
- // are never raised as a `SoftphoneError` at all. See the fuller comment on each in `core/errors.ts`
1728
- // for why. Copy kept here anyway so the catalogue and this file stay the same shape as every other
1729
- // code — `error-text.test.ts` would fail the build the moment they drifted.
1730
- "media/permission-denied": {
1731
- title: "Micr\xF3fono bloqueado",
1732
- body: "Perm\xEDtelo en tu navegador para poder llamar."
1733
- },
1734
- "media/no-input-device": {
1735
- title: "Sin micr\xF3fono",
1736
- body: "Conecta un micr\xF3fono y vuelve a intentarlo."
1737
- },
1738
- "media/webrtc-unsupported": {
1739
- title: "Llamadas no disponibles",
1740
- body: "Este navegador no admite llamadas. Prueba con Chrome, Edge o Firefox."
1741
- },
1742
- "media/playback-blocked": { title: "Toca la pantalla para activar el sonido" },
1743
- // ── Network / signalling ────────────────────────────────────────────────────
1744
- // "Centralita" rather than "SIP" or "registro": the user knows what a phone system is.
1745
- "net/registration-failed": {
1746
- title: "No se pudo conectar",
1747
- body: "No hemos podido conectar con la centralita. Reintenta en unos segundos."
1748
- },
1749
- "net/transport-failed": {
1750
- title: "Conexi\xF3n perdida",
1751
- body: "Se ha perdido la conexi\xF3n con la centralita."
1752
- },
1753
- // NEVER ACTUALLY SHOWN either — CallScreen's own "Reconectando…" already covers this, off the
1754
- // call's `reconnecting` state rather than this channel. See the comment in `core/errors.ts`.
1755
- "net/ice-failed": { title: "Recuperando el audio\u2026" },
1756
- // ── Call ────────────────────────────────────────────────────────────────────
1757
- /*
1758
- THE VOICE RULE FOR THIS BLOCK: say what HAPPENED, not what it is called.
1759
-
1760
- These were status labels — "Llamada perdida", "Llamada rechazada", "Sin respuesta". Each is a
1761
- name for a category rather than an account of an event, which is why they read as a database enum
1762
- someone forgot to translate into human. Worse, a label hides the ACTOR: "Llamada rechazada" never
1763
- says who rejected what, so the user reading it after declining a call and the user reading it
1764
- after being declined see the same three words.
1765
-
1766
- So each one is a short sentence about what the other person, or the phone, did — the form
1767
- `call/busy` already had, and the reason it was the only one here that read clearly.
1768
-
1769
- Impersonal third-person plural ("contestaron", "colgaron") throughout: the user does not know who
1770
- is at the other end, and it is what Spanish uses when the subject is unknown. Mixing it with the
1771
- singular across neighbouring lines reads as carelessness.
1772
-
1773
- LENGTH IS A HARD CONSTRAINT, not a preference. `.cv-dial-outcome` is one line at a fixed
1774
- `1.15rem`, and the keypad below it is `flex: 1` — a title that wraps resizes every key and shifts
1775
- the grid under a finger already reaching for it. `error-text.test.ts` caps this at 48 characters.
1776
- */
1777
- "call/not-answerable": { title: "Esa llamada ya ha terminado" },
1778
- /*
1779
- NOT "Comunicando", which is what this said and what sent us looking. SIP 486 is both "the line is
1780
- engaged" AND what almost every endpoint sends when a person presses decline — including our own
1781
- `reject()`. One code, two events, no way to tell them apart, so the copy has to hold both rather
1782
- than assert the less likely one. `call/rejected` can be definite: an endpoint sending 603 Decline
1783
- means exactly that.
1784
- */
1785
- "call/busy": { title: "No contestaron o la l\xEDnea estaba ocupada" },
1786
- "call/no-answer": { title: "No contestaron" },
1787
- "call/rejected": { title: "No aceptaron tu llamada" },
1788
- /*
1789
- The legacy CRM said "La persona que llamaba colgó antes de que pudieras contestar." — the right
1790
- fact, at three times the width of the line that has to hold it. This keeps the fact and drops the
1791
- words: the user needs to know the call was FOR them and that nobody is waiting on the line.
1792
- "Llamada perdida" alone tested badly for exactly the label reason above — it names the category
1793
- without saying anyone rang.
1794
- */
1795
- "call/missed": { title: "Colgaron antes de que pudieras contestar" },
1796
- // "No se pudo completar la llamada" was our own "something went wrong": true of every failure and
1797
- // useful for none. The number is what the user can actually act on.
1798
- "call/failed": { title: "No se pudo conectar con ese n\xFAmero" },
1799
- // The load-bearing half is the SECOND sentence: the original call is still up, and a user who
1800
- // thinks they dropped it will hang up on a client to "fix" it.
1801
- "call/transfer-failed": { title: "No se pudo transferir. Sigues en la llamada" },
1802
- "call/unsupported": { title: "Esta funci\xF3n no est\xE1 disponible" },
1803
- // ── Provider ────────────────────────────────────────────────────────────────
1804
- "provider/provision-failed": {
1805
- title: "No se pudo activar el tel\xE9fono",
1806
- body: "Si el problema contin\xFAa, contacta con soporte."
2295
+ return /* @__PURE__ */ jsxs("div", { className: "cv-gate", "data-state": state, "data-tone": "danger", children: [
2296
+ /* @__PURE__ */ jsx("span", { className: "cv-gate-mark", children: /* @__PURE__ */ jsx(MicOff, { size: 22 }) }),
2297
+ /* @__PURE__ */ jsx("h3", { className: "cv-gate-title", children: "Micr\xF3fono bloqueado" }),
2298
+ /* @__PURE__ */ jsx("p", { children: "Perm\xEDtelo en tu navegador para poder llamar." }),
2299
+ /* @__PURE__ */ jsxs("details", { className: "cv-help", children: [
2300
+ /* @__PURE__ */ jsxs("summary", { children: [
2301
+ /* @__PURE__ */ jsx(CircleHelp, { size: 13, "aria-hidden": true }),
2302
+ "C\xF3mo permitirlo"
2303
+ ] }),
2304
+ /* @__PURE__ */ jsx("ol", { children: guide.steps.map((step) => /* @__PURE__ */ jsx("li", { children: step }, step)) }),
2305
+ guide.url ? /* @__PURE__ */ jsxs("a", { href: guide.url, target: "_blank", rel: "noreferrer noopener", children: [
2306
+ "Gu\xEDa del navegador",
2307
+ /* @__PURE__ */ jsx(ExternalLink, { size: 11, "aria-hidden": true })
2308
+ ] }) : null
2309
+ ] })
2310
+ ] });
1807
2311
  }
1808
- };
2312
+ if (state === "warming") {
2313
+ return /* @__PURE__ */ jsxs("div", { className: "cv-gate", "data-state": state, children: [
2314
+ /* @__PURE__ */ jsx("span", { className: "cv-gate-mark", children: /* @__PURE__ */ jsx(Mic, { size: 22 }) }),
2315
+ /* @__PURE__ */ jsx("h3", { className: "cv-gate-title", children: "Permite el micr\xF3fono" }),
2316
+ /* @__PURE__ */ jsx("p", { children: "Acepta el aviso que muestra el navegador para continuar." })
2317
+ ] });
2318
+ }
2319
+ if (state === "checking") {
2320
+ return /* @__PURE__ */ jsxs("div", { className: "cv-gate", "data-state": state, children: [
2321
+ /* @__PURE__ */ jsx("span", { className: "cv-gate-mark", children: /* @__PURE__ */ jsx(Mic, { size: 22 }) }),
2322
+ /* @__PURE__ */ jsx("p", { children: "Preparando el micr\xF3fono\u2026" })
2323
+ ] });
2324
+ }
2325
+ return /* @__PURE__ */ jsxs("div", { className: "cv-gate", "data-state": state, children: [
2326
+ /* @__PURE__ */ jsx("span", { className: "cv-gate-mark", children: /* @__PURE__ */ jsx(Mic, { size: 22 }) }),
2327
+ /* @__PURE__ */ jsx("h3", { className: "cv-gate-title", children: "Permite el micr\xF3fono" }),
2328
+ /* @__PURE__ */ jsx("p", { children: "Lo necesitamos para que puedas hablar durante las llamadas." }),
2329
+ /* @__PURE__ */ jsx("button", { className: "cv-btn cv-btn--accent", onClick: onRequest, children: "Permitir micr\xF3fono" })
2330
+ ] });
2331
+ }
2332
+ function MicPermissionGate({ children }) {
2333
+ const { state, request } = useMicPermission();
2334
+ if (state === "ready") return /* @__PURE__ */ jsx(Fragment, { children });
2335
+ return /* @__PURE__ */ jsx("div", { className: "cv-softphone", children: /* @__PURE__ */ jsx(MicPrompt, { state, onRequest: () => void request() }) });
2336
+ }
1809
2337
  function ErrorGate({
1810
2338
  error,
1811
2339
  onRetry
@@ -1862,203 +2390,6 @@ function useCallOutcome() {
1862
2390
  clear
1863
2391
  };
1864
2392
  }
1865
-
1866
- // src/ui/audio/audio.ts
1867
- var TONE_HZ = 425;
1868
- function createRemoteAudio() {
1869
- const el = document.createElement("audio");
1870
- el.autoplay = true;
1871
- el.style.display = "none";
1872
- document.body.appendChild(el);
1873
- return {
1874
- attach(stream) {
1875
- el.srcObject = stream;
1876
- void el.play().catch(() => {
1877
- });
1878
- },
1879
- detach() {
1880
- el.srcObject = null;
1881
- },
1882
- destroy() {
1883
- el.srcObject = null;
1884
- el.remove();
1885
- },
1886
- async setOutputDevice(deviceId) {
1887
- if (typeof el.setSinkId !== "function") return;
1888
- try {
1889
- await el.setSinkId(deviceId ?? "");
1890
- } catch {
1891
- }
1892
- }
1893
- };
1894
- }
1895
- var shared = null;
1896
- function sharedContext() {
1897
- if (shared) return shared;
1898
- const Ctor = typeof AudioContext !== "undefined" ? AudioContext : globalThis.webkitAudioContext;
1899
- if (!Ctor) return null;
1900
- shared = new Ctor();
1901
- return shared;
1902
- }
1903
- function primeAudio() {
1904
- const ctx = sharedContext();
1905
- if (ctx && ctx.state === "suspended") void ctx.resume?.().catch(() => {
1906
- });
1907
- }
1908
- function playRingtone() {
1909
- const ctx = sharedContext();
1910
- if (!ctx) return { stop() {
1911
- } };
1912
- const gain = ctx.createGain();
1913
- gain.gain.value = 0;
1914
- gain.connect(ctx.destination);
1915
- const osc = ctx.createOscillator();
1916
- osc.type = "sine";
1917
- osc.frequency.value = TONE_HZ;
1918
- osc.connect(gain);
1919
- osc.start();
1920
- const LEVEL = 0.8;
1921
- const RAMP = 0.02;
1922
- const burst = (at, dur) => {
1923
- gain.gain.setValueAtTime(0, at);
1924
- gain.gain.linearRampToValueAtTime(LEVEL, at + RAMP);
1925
- gain.gain.setValueAtTime(LEVEL, at + dur - RAMP);
1926
- gain.gain.linearRampToValueAtTime(0, at + dur);
1927
- };
1928
- const scheduleCycle = (at) => {
1929
- burst(at, 0.4);
1930
- burst(at + 0.6, 0.4);
1931
- return 3;
1932
- };
1933
- const LOOKAHEAD = 0.5;
1934
- const TICK_MS = 150;
1935
- const LEAD = 0.08;
1936
- let stopped = false;
1937
- let timer = null;
1938
- let nextAt = 0;
1939
- const pump = () => {
1940
- while (nextAt < ctx.currentTime + LOOKAHEAD) nextAt += scheduleCycle(nextAt);
1941
- };
1942
- void Promise.resolve(ctx.resume?.()).catch(() => {
1943
- }).finally(() => {
1944
- if (stopped) return;
1945
- nextAt = ctx.currentTime + LEAD;
1946
- pump();
1947
- timer = setInterval(pump, TICK_MS);
1948
- });
1949
- return {
1950
- stop() {
1951
- stopped = true;
1952
- if (timer) clearInterval(timer);
1953
- try {
1954
- osc.stop();
1955
- } catch {
1956
- }
1957
- try {
1958
- gain.disconnect();
1959
- } catch {
1960
- }
1961
- }
1962
- };
1963
- }
1964
- function playCallEndedTone() {
1965
- const ctx = sharedContext();
1966
- if (!ctx) return;
1967
- const gain = ctx.createGain();
1968
- gain.gain.value = 0;
1969
- gain.connect(ctx.destination);
1970
- const osc = ctx.createOscillator();
1971
- osc.type = "sine";
1972
- osc.frequency.value = TONE_HZ;
1973
- osc.connect(gain);
1974
- osc.start();
1975
- const LEVEL = 0.55;
1976
- const RAMP = 8e-3;
1977
- const BEEP = 0.25;
1978
- void Promise.resolve(ctx.resume?.()).catch(() => {
1979
- }).finally(() => {
1980
- const at = ctx.currentTime + 0.02;
1981
- gain.gain.setValueAtTime(0, at);
1982
- gain.gain.linearRampToValueAtTime(LEVEL, at + RAMP);
1983
- gain.gain.setValueAtTime(LEVEL, at + BEEP - RAMP);
1984
- gain.gain.linearRampToValueAtTime(0, at + BEEP);
1985
- const totalMs = (at + BEEP - ctx.currentTime) * 1e3;
1986
- setTimeout(() => {
1987
- try {
1988
- osc.stop();
1989
- } catch {
1990
- }
1991
- try {
1992
- gain.disconnect();
1993
- } catch {
1994
- }
1995
- }, totalMs);
1996
- });
1997
- }
1998
- var ringbackUrl = null;
1999
- function ringbackSource() {
2000
- if (ringbackUrl) return ringbackUrl;
2001
- const sampleRate = 48e3;
2002
- const channels = 2;
2003
- const bytesPerSample = 2;
2004
- const amp = 0.5;
2005
- const toneSec = 1.5;
2006
- const silenceSec = 3;
2007
- const totalFrames = Math.round((toneSec + silenceSec) * sampleRate);
2008
- const toneFrames = Math.round(toneSec * sampleRate);
2009
- const ramp = Math.round(0.02 * sampleRate);
2010
- const blockAlign = channels * bytesPerSample;
2011
- const dataSize = totalFrames * blockAlign;
2012
- const buffer = new ArrayBuffer(44 + dataSize);
2013
- const view = new DataView(buffer);
2014
- const str = (off, s) => {
2015
- for (let i = 0; i < s.length; i++) view.setUint8(off + i, s.charCodeAt(i));
2016
- };
2017
- str(0, "RIFF");
2018
- view.setUint32(4, 36 + dataSize, true);
2019
- str(8, "WAVE");
2020
- str(12, "fmt ");
2021
- view.setUint32(16, 16, true);
2022
- view.setUint16(20, 1, true);
2023
- view.setUint16(22, channels, true);
2024
- view.setUint32(24, sampleRate, true);
2025
- view.setUint32(28, sampleRate * blockAlign, true);
2026
- view.setUint16(32, blockAlign, true);
2027
- view.setUint16(34, bytesPerSample * 8, true);
2028
- str(36, "data");
2029
- view.setUint32(40, dataSize, true);
2030
- for (let i = 0; i < totalFrames; i++) {
2031
- let sample = 0;
2032
- if (i < toneFrames) {
2033
- let env = 1;
2034
- if (i < ramp) env = i / ramp;
2035
- else if (i > toneFrames - ramp) env = Math.max(0, (toneFrames - i) / ramp);
2036
- sample = Math.sin(2 * Math.PI * TONE_HZ * (i / sampleRate)) * amp * env;
2037
- }
2038
- const s16 = Math.round(sample * 32767);
2039
- const off = 44 + i * blockAlign;
2040
- for (let c = 0; c < channels; c++) view.setInt16(off + c * bytesPerSample, s16, true);
2041
- }
2042
- ringbackUrl = URL.createObjectURL(new Blob([buffer], { type: "audio/wav" }));
2043
- return ringbackUrl;
2044
- }
2045
- function playRingback() {
2046
- const el = document.createElement("audio");
2047
- el.src = ringbackSource();
2048
- el.loop = true;
2049
- el.style.display = "none";
2050
- document.body.appendChild(el);
2051
- void el.play().catch(() => {
2052
- });
2053
- return {
2054
- stop() {
2055
- el.pause();
2056
- el.remove();
2057
- }
2058
- };
2059
- }
2060
-
2061
- // src/ui/audio/useCallAudio.ts
2062
2393
  function useCallAudio(enabled) {
2063
2394
  const softphone = useSoftphone();
2064
2395
  const remote = useRef(null);
@@ -2137,6 +2468,94 @@ function useCallAudio(enabled) {
2137
2468
  };
2138
2469
  }, [softphone, enabled]);
2139
2470
  }
2471
+ function TransferNotices({
2472
+ notices,
2473
+ dismiss
2474
+ }) {
2475
+ const softphone = useSoftphone();
2476
+ const now = useTicker(notices.length > 0, 15e3);
2477
+ const callBack = (notice) => {
2478
+ dismiss(notice.id);
2479
+ if (notice.caller) void softphone.call(notice.caller).catch(() => {
2480
+ });
2481
+ };
2482
+ return /* @__PURE__ */ jsxs(
2483
+ "div",
2484
+ {
2485
+ className: "cv-transfer-notices",
2486
+ role: "status",
2487
+ part: "transfer-notices",
2488
+ "data-has-notices": notices.length > 0,
2489
+ children: [
2490
+ notices.length > 1 ? /* @__PURE__ */ jsxs("p", { className: "cv-transfer-notices-count", children: [
2491
+ notices.length,
2492
+ " traspasos sin conectar"
2493
+ ] }) : null,
2494
+ notices.map((notice) => /* @__PURE__ */ jsxs("div", { className: "cv-transfer-notice", part: "transfer-notice", children: [
2495
+ /* @__PURE__ */ jsx("span", { className: "cv-transfer-notice-mark", "aria-hidden": true, children: /* @__PURE__ */ jsx(PhoneMissed, { size: 16 }) }),
2496
+ /* @__PURE__ */ jsxs("div", { className: "cv-transfer-notice-body", children: [
2497
+ /* @__PURE__ */ jsxs("div", { className: "cv-transfer-notice-header", children: [
2498
+ /* @__PURE__ */ jsxs("p", { className: "cv-transfer-notice-title", children: [
2499
+ "El traspaso a ",
2500
+ notice.target,
2501
+ " no se conect\xF3"
2502
+ ] }),
2503
+ /* @__PURE__ */ jsx(
2504
+ "button",
2505
+ {
2506
+ type: "button",
2507
+ className: "cv-transfer-notice-dismiss",
2508
+ onClick: () => dismiss(notice.id),
2509
+ "aria-label": "Descartar aviso",
2510
+ part: "transfer-notice-dismiss",
2511
+ children: /* @__PURE__ */ jsx(X, { size: 14 })
2512
+ }
2513
+ )
2514
+ ] }),
2515
+ notice.caller ? /* @__PURE__ */ jsxs("p", { className: "cv-transfer-notice-subtitle", children: [
2516
+ notice.caller,
2517
+ " se ha desconectado."
2518
+ ] }) : null,
2519
+ /* @__PURE__ */ jsxs("div", { className: "cv-transfer-notice-footer", children: [
2520
+ /* @__PURE__ */ jsx("p", { className: "cv-transfer-notice-age", children: formatAge(elapsedSeconds(notice.at, now)) }),
2521
+ notice.caller ? /* @__PURE__ */ jsxs(
2522
+ "button",
2523
+ {
2524
+ type: "button",
2525
+ className: "cv-btn cv-btn--accent cv-transfer-notice-callback",
2526
+ onClick: () => callBack(notice),
2527
+ part: "transfer-notice-callback",
2528
+ children: [
2529
+ /* @__PURE__ */ jsx(Phone, { size: 14, "aria-hidden": true }),
2530
+ "Llamar de vuelta"
2531
+ ]
2532
+ }
2533
+ ) : null
2534
+ ] })
2535
+ ] })
2536
+ ] }, notice.id))
2537
+ ]
2538
+ }
2539
+ );
2540
+ }
2541
+ function useTransferNotices() {
2542
+ const softphone = useSoftphone();
2543
+ const [notices, setNotices] = useState([]);
2544
+ const nextId = useRef(0);
2545
+ const dismiss = useCallback((id) => {
2546
+ setNotices((prev) => prev.filter((n) => n.id !== id));
2547
+ }, []);
2548
+ useEffect(
2549
+ () => softphone.on("transferOutcome" /* TransferOutcome */, (payload) => {
2550
+ if (payload.status === "not-connected") {
2551
+ const id = nextId.current++;
2552
+ setNotices((prev) => [...prev, { id, target: payload.target, caller: payload.caller, at: payload.at }]);
2553
+ }
2554
+ }),
2555
+ [softphone]
2556
+ );
2557
+ return { notices, dismiss };
2558
+ }
2140
2559
  function SoftphoneWidget(props) {
2141
2560
  const {
2142
2561
  contacts,
@@ -2161,7 +2580,9 @@ function SoftphoneWidget(props) {
2161
2580
  useCallAudio(sounds);
2162
2581
  const blocked = requireMic && mic.state !== "ready";
2163
2582
  const fatal = useLastError();
2583
+ const screenIsFree = !call && !blocked && !fatal && !isFollower;
2164
2584
  const { outcome, clear: clearOutcome } = useCallOutcome();
2585
+ const { notices: transferNotices, dismiss: dismissTransferNotice } = useTransferNotices();
2165
2586
  const retry = () => {
2166
2587
  void (async () => {
2167
2588
  try {
@@ -2185,8 +2606,9 @@ function SoftphoneWidget(props) {
2185
2606
  className: className ? `cv-softphone ${className}` : "cv-softphone",
2186
2607
  ...theme === "system" ? {} : { "data-cv-theme": theme },
2187
2608
  children: [
2188
- /* @__PURE__ */ jsx("div", { className: "cv-body", children: isFollower ? /* @__PURE__ */ jsx(LeaderGate, {}) : blocked ? /* @__PURE__ */ jsx(MicPrompt, { state: mic.state, onRequest: () => void mic.request() }) : call ? /* @__PURE__ */ jsx(CallScreen, { call, ...incomingCallAction ? { incomingCallAction } : {} }) : fatal ? /* @__PURE__ */ jsx(ErrorGate, { error: fatal, onRetry: retry }) : tab === "contacts" && contactsSource ? /* @__PURE__ */ jsx(ContactsTab, { source: contactsSource, ...contactAction ? { contactAction } : {} }) : tab === "history" && historySource ? /* @__PURE__ */ jsx(HistoryTab, { source: historySource }) : tab === "settings" ? /* @__PURE__ */ jsx(SettingsTab, {}) : /* @__PURE__ */ jsx(Dialpad, { outcome, onIntent: clearOutcome }) }),
2189
- call || blocked || fatal || isFollower ? null : /* @__PURE__ */ jsx("div", { className: "cv-tabs", role: "tablist", children: tabs.map(({ id, icon: Icon3, label }) => /* @__PURE__ */ jsxs(
2609
+ screenIsFree ? /* @__PURE__ */ jsx(TransferNotices, { notices: transferNotices, dismiss: dismissTransferNotice }) : null,
2610
+ /* @__PURE__ */ jsx("div", { className: "cv-body", children: isFollower ? /* @__PURE__ */ jsx(LeaderGate, {}) : blocked ? /* @__PURE__ */ jsx(MicPrompt, { state: mic.state, onRequest: () => void mic.request() }) : call ? /* @__PURE__ */ jsx(CallScreen, { call, keyTones: sounds, ...incomingCallAction ? { incomingCallAction } : {} }) : fatal ? /* @__PURE__ */ jsx(ErrorGate, { error: fatal, onRetry: retry }) : tab === "contacts" && contactsSource ? /* @__PURE__ */ jsx(ContactsTab, { source: contactsSource, ...contactAction ? { contactAction } : {} }) : tab === "history" && historySource ? /* @__PURE__ */ jsx(HistoryTab, { source: historySource }) : tab === "settings" ? /* @__PURE__ */ jsx(SettingsTab, {}) : /* @__PURE__ */ jsx(Dialpad, { outcome, onIntent: clearOutcome, keyTones: sounds }) }),
2611
+ screenIsFree ? /* @__PURE__ */ jsx("div", { className: "cv-tabs", role: "tablist", children: tabs.map(({ id, icon: Icon3, label }) => /* @__PURE__ */ jsxs(
2190
2612
  "button",
2191
2613
  {
2192
2614
  role: "tab",
@@ -2199,7 +2621,7 @@ function SoftphoneWidget(props) {
2199
2621
  ]
2200
2622
  },
2201
2623
  id
2202
- )) })
2624
+ )) }) : null
2203
2625
  ]
2204
2626
  }
2205
2627
  );