@coventia/softphone 0.1.0 → 0.2.0

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-DUVnDxsz.d.cts} +152 -5
  2. package/dist/{call-BTYlT2SU.d.ts → call-DUVnDxsz.d.ts} +152 -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-3V2L6UTK.js → chunk-LMJXX4YM.js} +17 -3
  6. package/dist/chunk-LMJXX4YM.js.map +1 -0
  7. package/dist/{chunk-5BGEHMPP.js → chunk-YWI6EQYS.js} +40 -7
  8. package/dist/chunk-YWI6EQYS.js.map +1 -0
  9. package/dist/element/index.d.ts +154 -5
  10. package/dist/element/index.js +512 -152
  11. package/dist/element/index.js.map +1 -1
  12. package/dist/{engine-D9nbeY08.d.cts → engine-CDjkrXHY.d.cts} +1 -1
  13. package/dist/{engine-Doa6Brkm.d.ts → engine-EPCKf-Bl.d.ts} +1 -1
  14. package/dist/index.cjs +396 -14
  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 +256 -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-BtdgZWkm.d.ts} +17 -2
  26. package/dist/{softphone-DHmhotbf.d.ts → softphone-DHG9MTNg.d.cts} +17 -2
  27. package/dist/softphone.css +395 -1
  28. package/dist/{sources-N4BOEDRL.js → sources-5L2R4HTV.js} +104 -5
  29. package/dist/sources-5L2R4HTV.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 +490 -139
  37. package/dist/ui/index.cjs.map +1 -1
  38. package/dist/ui/index.d.cts +2 -2
  39. package/dist/ui/index.d.ts +2 -2
  40. package/dist/ui/index.js +478 -141
  41. package/dist/ui/index.js.map +1 -1
  42. package/dist/zadarma/index.cjs +38 -5
  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-4VOPIBXG.js +5 -0
  48. package/dist/{zadarma-engine-DN2SZSQ3.js.map → zadarma-engine-4VOPIBXG.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
 
@@ -679,6 +679,125 @@ function elapsedSeconds(since, now) {
679
679
  if (!since) return 0;
680
680
  return Math.floor((now - since.getTime()) / 1e3);
681
681
  }
682
+ function formatAge(totalSeconds) {
683
+ const s = Math.max(0, Math.floor(totalSeconds));
684
+ if (s < 60) return "justo ahora";
685
+ const mm = Math.floor(s / 60);
686
+ if (mm < 60) return `hace ${mm} min`;
687
+ const hh = Math.floor(mm / 60);
688
+ return `hace ${hh} h`;
689
+ }
690
+
691
+ // src/ui/lib/error-text.ts
692
+ var ERROR_TEXT = {
693
+ // ── Engine lifecycle ────────────────────────────────────────────────────────
694
+ // Integrator mistakes rather than user-facing failures, but they must still read as something
695
+ // rather than as a blank panel if one ever reaches the UI.
696
+ "engine/duplicate-instance": {
697
+ title: "Tel\xE9fono duplicado",
698
+ body: "Hay otro tel\xE9fono abierto en esta p\xE1gina. Cierra uno y vuelve a cargar."
699
+ },
700
+ "engine/not-started": { title: "El tel\xE9fono a\xFAn no est\xE1 listo" },
701
+ "engine/destroyed": {
702
+ title: "Tel\xE9fono cerrado",
703
+ body: "Vuelve a cargar la p\xE1gina para usarlo de nuevo."
704
+ },
705
+ // ── Auth ────────────────────────────────────────────────────────────────────
706
+ // Never "token": the user has no idea what one is. What they can act on is the session.
707
+ "auth/token-fetch-failed": {
708
+ title: "Sin conexi\xF3n con el servidor",
709
+ body: "No se ha podido activar el tel\xE9fono. Reint\xE9ntalo en unos segundos."
710
+ },
711
+ "auth/token-rejected": {
712
+ title: "Sesi\xF3n caducada",
713
+ body: "Vuelve a iniciar sesi\xF3n para poder llamar."
714
+ },
715
+ // ── Media ───────────────────────────────────────────────────────────────────
716
+ // Verbatim from MicPermissionGate, which already says these well and says them first.
717
+ //
718
+ // NEVER ACTUALLY SHOWN via this table — these four codes (through `media/playback-blocked`, below)
719
+ // are never raised as a `SoftphoneError` at all. See the fuller comment on each in `core/errors.ts`
720
+ // for why. Copy kept here anyway so the catalogue and this file stay the same shape as every other
721
+ // code — `error-text.test.ts` would fail the build the moment they drifted.
722
+ "media/permission-denied": {
723
+ title: "Micr\xF3fono bloqueado",
724
+ body: "Perm\xEDtelo en tu navegador para poder llamar."
725
+ },
726
+ "media/no-input-device": {
727
+ title: "Sin micr\xF3fono",
728
+ body: "Conecta un micr\xF3fono y vuelve a intentarlo."
729
+ },
730
+ "media/webrtc-unsupported": {
731
+ title: "Llamadas no disponibles",
732
+ body: "Este navegador no admite llamadas. Prueba con Chrome, Edge o Firefox."
733
+ },
734
+ "media/playback-blocked": { title: "Toca la pantalla para activar el sonido" },
735
+ // ── Network / signalling ────────────────────────────────────────────────────
736
+ // "Centralita" rather than "SIP" or "registro": the user knows what a phone system is.
737
+ "net/registration-failed": {
738
+ title: "No se pudo conectar",
739
+ body: "No hemos podido conectar con la centralita. Reintenta en unos segundos."
740
+ },
741
+ "net/transport-failed": {
742
+ title: "Conexi\xF3n perdida",
743
+ body: "Se ha perdido la conexi\xF3n con la centralita."
744
+ },
745
+ // NEVER ACTUALLY SHOWN either — CallScreen's own "Reconectando…" already covers this, off the
746
+ // call's `reconnecting` state rather than this channel. See the comment in `core/errors.ts`.
747
+ "net/ice-failed": { title: "Recuperando el audio\u2026" },
748
+ // ── Call ────────────────────────────────────────────────────────────────────
749
+ /*
750
+ THE VOICE RULE FOR THIS BLOCK: say what HAPPENED, not what it is called.
751
+
752
+ These were status labels — "Llamada perdida", "Llamada rechazada", "Sin respuesta". Each is a
753
+ name for a category rather than an account of an event, which is why they read as a database enum
754
+ someone forgot to translate into human. Worse, a label hides the ACTOR: "Llamada rechazada" never
755
+ says who rejected what, so the user reading it after declining a call and the user reading it
756
+ after being declined see the same three words.
757
+
758
+ So each one is a short sentence about what the other person, or the phone, did — the form
759
+ `call/busy` already had, and the reason it was the only one here that read clearly.
760
+
761
+ Impersonal third-person plural ("contestaron", "colgaron") throughout: the user does not know who
762
+ is at the other end, and it is what Spanish uses when the subject is unknown. Mixing it with the
763
+ singular across neighbouring lines reads as carelessness.
764
+
765
+ LENGTH IS A HARD CONSTRAINT, not a preference. `.cv-dial-outcome` is one line at a fixed
766
+ `1.15rem`, and the keypad below it is `flex: 1` — a title that wraps resizes every key and shifts
767
+ the grid under a finger already reaching for it. `error-text.test.ts` caps this at 48 characters.
768
+ */
769
+ "call/not-answerable": { title: "Esa llamada ya ha terminado" },
770
+ /*
771
+ NOT "Comunicando", which is what this said and what sent us looking. SIP 486 is both "the line is
772
+ engaged" AND what almost every endpoint sends when a person presses decline — including our own
773
+ `reject()`. One code, two events, no way to tell them apart, so the copy has to hold both rather
774
+ than assert the less likely one. `call/rejected` can be definite: an endpoint sending 603 Decline
775
+ means exactly that.
776
+ */
777
+ "call/busy": { title: "No contestaron o la l\xEDnea estaba ocupada" },
778
+ "call/no-answer": { title: "No contestaron" },
779
+ "call/rejected": { title: "No aceptaron tu llamada" },
780
+ /*
781
+ The legacy CRM said "La persona que llamaba colgó antes de que pudieras contestar." — the right
782
+ fact, at three times the width of the line that has to hold it. This keeps the fact and drops the
783
+ words: the user needs to know the call was FOR them and that nobody is waiting on the line.
784
+ "Llamada perdida" alone tested badly for exactly the label reason above — it names the category
785
+ without saying anyone rang.
786
+ */
787
+ "call/missed": { title: "Colgaron antes de que pudieras contestar" },
788
+ // "No se pudo completar la llamada" was our own "something went wrong": true of every failure and
789
+ // useful for none. The number is what the user can actually act on.
790
+ "call/failed": { title: "No se pudo conectar con ese n\xFAmero" },
791
+ // The load-bearing half is the SECOND sentence: the original call is still up, and a user who
792
+ // thinks they dropped it will hang up on a client to "fix" it.
793
+ "call/transfer-failed": { title: "No se pudo transferir. Sigues en la llamada" },
794
+ "call/unsupported": { title: "Esta funci\xF3n no est\xE1 disponible" },
795
+ // ── Provider ────────────────────────────────────────────────────────────────
796
+ "provider/provision-failed": {
797
+ title: "No se pudo activar el tel\xE9fono",
798
+ body: "Si el problema contin\xFAa, contacta con soporte."
799
+ }
800
+ };
682
801
  function useTicker(enabled, intervalMs = 1e3) {
683
802
  const [now, setNow] = useState(() => Date.now());
684
803
  useEffect(() => {
@@ -791,17 +910,195 @@ var STATUS_LABEL = {
791
910
  reconnecting: "Reconectando\u2026",
792
911
  held: "En espera"
793
912
  };
913
+ var CONSULT_FAIL_CODE = {
914
+ busy: "call/busy",
915
+ "no-answer": "call/no-answer",
916
+ rejected: "call/rejected",
917
+ failed: "call/failed"
918
+ };
919
+ function consultFailText(reason) {
920
+ return ERROR_TEXT[CONSULT_FAIL_CODE[reason] ?? "call/transfer-failed"].title;
921
+ }
922
+ var MIN_CONSULT_HOLD_SECONDS = 10;
923
+ function ConsultScreen({
924
+ mainName,
925
+ consultView,
926
+ now,
927
+ onReturn,
928
+ onComplete,
929
+ onHangupAll
930
+ }) {
931
+ const s = consultView.state;
932
+ const answered = s === "active";
933
+ const name = consultView.remote.displayName;
934
+ const number = consultView.remote.number;
935
+ const showNumber = number !== name;
936
+ const heldSeconds = elapsedSeconds(consultView.answeredAt, now);
937
+ const [rawM = "0", ss = "00"] = formatDuration(heldSeconds).split(":");
938
+ const mm = rawM.padStart(2, "0");
939
+ const canComplete = heldSeconds >= MIN_CONSULT_HOLD_SECONDS;
940
+ return /* @__PURE__ */ jsxs("div", { className: "cv-view", children: [
941
+ /* @__PURE__ */ jsxs("div", { className: "cv-call", children: [
942
+ /* @__PURE__ */ jsxs("button", { type: "button", className: "cv-consult-hold-banner", onClick: onReturn, children: [
943
+ /* @__PURE__ */ jsx(Pause, { size: 13, "aria-hidden": true }),
944
+ mainName,
945
+ " en espera \xB7 Volver"
946
+ ] }),
947
+ /* @__PURE__ */ jsxs("div", { className: "cv-avatar-wrap", children: [
948
+ /* @__PURE__ */ jsx("div", { className: "cv-avatar", "aria-hidden": true, children: initials(name) }),
949
+ answered ? /* @__PURE__ */ jsx("span", { className: "cv-avatar-dot", "aria-hidden": true }) : null
950
+ ] }),
951
+ /* @__PURE__ */ jsx("div", { className: "cv-call-name", children: name }),
952
+ showNumber ? /* @__PURE__ */ jsx("div", { className: "cv-call-number", children: number }) : null,
953
+ answered ? /* @__PURE__ */ jsxs("div", { className: "cv-timer", "aria-label": "Duraci\xF3n de la llamada", children: [
954
+ /* @__PURE__ */ jsx("b", { children: mm }),
955
+ /* @__PURE__ */ jsx("i", { children: ":" }),
956
+ /* @__PURE__ */ jsx("b", { children: ss })
957
+ ] }) : /* @__PURE__ */ jsx("div", { className: "cv-call-status", children: STATUS_LABEL[s] ?? "" })
958
+ ] }),
959
+ /* @__PURE__ */ jsxs("div", { className: "cv-controls", children: [
960
+ /* @__PURE__ */ jsxs("div", { className: "cv-transfer-actions", children: [
961
+ answered ? /* @__PURE__ */ jsx("button", { className: "cv-btn cv-btn--accent", onClick: onComplete, disabled: !canComplete, children: "Completar" }) : null,
962
+ /* @__PURE__ */ jsx("button", { className: "cv-btn", onClick: onReturn, children: "Cancelar" })
963
+ ] }),
964
+ /* @__PURE__ */ jsx("div", { className: "cv-controls-primary", children: /* @__PURE__ */ jsxs("div", { className: "cv-ctrl-item", children: [
965
+ /* @__PURE__ */ jsx(
966
+ "button",
967
+ {
968
+ type: "button",
969
+ className: "cv-round cv-round--hangup",
970
+ onClick: onHangupAll,
971
+ "aria-label": "Colgar todo",
972
+ children: /* @__PURE__ */ jsx(PhoneOff, { size: 26 })
973
+ }
974
+ ),
975
+ /* @__PURE__ */ jsx("span", { className: "cv-ctrl-label", "aria-hidden": true, children: "Colgar todo" })
976
+ ] }) })
977
+ ] })
978
+ ] });
979
+ }
980
+ var PRESENCE_LABEL = {
981
+ disponible: "Disponible",
982
+ en_llamada: "En llamada",
983
+ desconectado: "Desconectado"
984
+ };
985
+ var PRESENCE_RANK = {
986
+ disponible: 0,
987
+ en_llamada: 1,
988
+ desconectado: 2
989
+ };
990
+ function filterAgents(agents, query) {
991
+ const q = query.trim().toLowerCase();
992
+ return agents.filter((a) => !q || (a.name ?? "").toLowerCase().includes(q) || a.extension.includes(q)).sort(
993
+ (a, b) => PRESENCE_RANK[a.state] - PRESENCE_RANK[b.state] || (a.name ?? a.extension).localeCompare(b.name ?? b.extension)
994
+ );
995
+ }
996
+ function truncateForButton(text, max = 16) {
997
+ return text.length > max ? `${text.slice(0, max)}\u2026` : text;
998
+ }
999
+ function TransferPicker({
1000
+ agents,
1001
+ myUserId,
1002
+ query,
1003
+ onSelect
1004
+ }) {
1005
+ const others = agents.filter((a) => a.userId !== myUserId);
1006
+ if (others.length === 0) return null;
1007
+ const filtered = filterAgents(others, query);
1008
+ return /* @__PURE__ */ jsxs("div", { className: "cv-presence", children: [
1009
+ /* @__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) => {
1010
+ const isBusy = agent.state === "en_llamada";
1011
+ return /* @__PURE__ */ jsxs(
1012
+ "button",
1013
+ {
1014
+ type: "button",
1015
+ role: "option",
1016
+ className: "cv-presence-item",
1017
+ disabled: isBusy,
1018
+ onClick: () => onSelect(agent.extension),
1019
+ children: [
1020
+ /* @__PURE__ */ jsx("span", { className: "cv-presence-dot", "data-state": agent.state, "aria-hidden": true }),
1021
+ /* @__PURE__ */ jsx("span", { className: "cv-presence-name", children: agent.name ?? agent.extension }),
1022
+ /* @__PURE__ */ jsxs("span", { className: "cv-presence-meta", children: [
1023
+ agent.extension,
1024
+ " \xB7 ",
1025
+ PRESENCE_LABEL[agent.state]
1026
+ ] })
1027
+ ]
1028
+ },
1029
+ agent.userId
1030
+ );
1031
+ }) }),
1032
+ /* @__PURE__ */ jsx("p", { className: "cv-presence-legend", children: "Desconectado: sin conexi\xF3n en el softphone web. Puede atender desde otro dispositivo." })
1033
+ ] });
1034
+ }
794
1035
  function CallScreen({
795
1036
  call,
796
1037
  incomingCallAction
797
1038
  }) {
798
1039
  const view = useCall(call);
1040
+ const softphone = useSoftphone();
799
1041
  const [showTransfer, setShowTransfer] = useState(false);
800
1042
  const [transferTo, setTransferTo] = useState("");
1043
+ const [consultCall, setConsultCall] = useState(null);
1044
+ const [consultOutcome, setConsultOutcome] = useState(null);
1045
+ const [myUserId, setMyUserId] = useState(null);
1046
+ const consultView = useCall(consultCall);
1047
+ const consultIntentRef = useRef(null);
801
1048
  const [showDevices, setShowDevices] = useState(false);
802
1049
  const controlsRef = useRef(null);
803
1050
  const now = useTicker(view?.state === "active");
1051
+ const consultNow = useTicker(consultView?.state === "active");
804
1052
  const isConnected = view ? view.state === "active" || view.state === "held" || view.state === "reconnecting" : false;
1053
+ const transferPickerActive = isConnected && showTransfer && !consultCall;
1054
+ const presence = usePresence(transferPickerActive);
1055
+ useEffect(() => {
1056
+ const listAgents = softphone.sources?.listAgents;
1057
+ if (!listAgents) return;
1058
+ let cancelled = false;
1059
+ void listAgents().then((d) => !cancelled && setMyUserId(d.me?.userId ?? null)).catch(() => {
1060
+ });
1061
+ return () => {
1062
+ cancelled = true;
1063
+ };
1064
+ }, [softphone]);
1065
+ useEffect(() => {
1066
+ if (!consultCall) return;
1067
+ return consultCall.on("ended" /* Ended */, ({ reason }) => {
1068
+ const intent = consultIntentRef.current;
1069
+ consultIntentRef.current = null;
1070
+ setConsultCall(null);
1071
+ if (!intent) {
1072
+ setConsultOutcome(reason);
1073
+ if (call.state !== "ended") void call.unhold();
1074
+ }
1075
+ });
1076
+ }, [consultCall, call]);
1077
+ const startConsult = async (target) => {
1078
+ const t = (target ?? transferTo).trim();
1079
+ if (!t) return;
1080
+ setConsultOutcome(null);
1081
+ setConsultCall(await call.startConsult(t));
1082
+ };
1083
+ const selectPresenceAgent = (extension) => {
1084
+ setTransferTo(extension);
1085
+ void startConsult(extension);
1086
+ };
1087
+ const [quickTransfer, setQuickTransfer] = useState(false);
1088
+ const selectPresenceAgentAuto = (extension) => {
1089
+ void call.transfer(extension);
1090
+ };
1091
+ const completeTransfer = async () => {
1092
+ if (!consultCall) return;
1093
+ const target = transferTo.trim();
1094
+ consultIntentRef.current = "complete";
1095
+ await consultCall.hangup();
1096
+ await call.transfer(target);
1097
+ };
1098
+ const cancelConsult = async () => {
1099
+ consultIntentRef.current = "cancel";
1100
+ await call.cancelConsult();
1101
+ };
805
1102
  useEffect(() => {
806
1103
  if (!showDevices) return;
807
1104
  const onKey = (e) => {
@@ -826,6 +1123,19 @@ function CallScreen({
826
1123
  const name = view.remote.displayName;
827
1124
  const number = view.remote.number;
828
1125
  const showNumber = number !== name;
1126
+ if (consultCall && consultView) {
1127
+ return /* @__PURE__ */ jsx(
1128
+ ConsultScreen,
1129
+ {
1130
+ mainName: name,
1131
+ consultView,
1132
+ now: consultNow,
1133
+ onReturn: () => void cancelConsult(),
1134
+ onComplete: () => void completeTransfer(),
1135
+ onHangupAll: () => void call.hangup()
1136
+ }
1137
+ );
1138
+ }
829
1139
  const incomingAction = isRinging && view.remote.contact && incomingCallAction ? incomingCallAction : null;
830
1140
  const IncomingActionIcon = incomingAction?.icon ?? ExternalLink;
831
1141
  const [rawM = "0", ss = "00"] = formatDuration(elapsedSeconds(view.answeredAt, now)).split(":");
@@ -844,7 +1154,79 @@ function CallScreen({
844
1154
  STATUS_LABEL[s] ?? ""
845
1155
  ] }),
846
1156
  /* @__PURE__ */ jsx("div", { className: "cv-settings-section-body", children: /* @__PURE__ */ jsx(DevicePickers, {}) })
847
- ] }) : /* @__PURE__ */ jsxs(Fragment, { children: [
1157
+ ] }) : transferPickerActive ? (
1158
+ /*
1159
+ Found live (2026-09-29): the agent list a moment ago picked between showing ONE agent row
1160
+ or forcing a scroll — `.cv-presence-list`'s own `max-height` had already been trimmed twice
1161
+ (T7, then T3) chasing that, and both times the room it was chasing lived somewhere else.
1162
+ The full avatar/name/giant-timer block above stayed on screen, unconditionally, the WHOLE
1163
+ time the picker was open — exactly the anti-pattern `.cv-call-devices-inline` (above) was
1164
+ already built to avoid for the pre-connect device picker: "an inline swap, not an overlay,
1165
+ so it gets the full box, not a leftover sliver." The picker never got that same treatment.
1166
+ Measured live (a temporary harness, `playground/react/picker-harness.*`, real Chromium at
1167
+ this SDK's own real embed footprint, 310×520): the avatar+name+timer block alone cost
1168
+ ~150px of `.cv-call`'s ~340px of usable height — more than the entire agent list, the mode
1169
+ toggle, and the free-text row combined. Reusing `.cv-call-devices-inline`'s OWN "who's
1170
+ calling doesn't disappear" compact context line (below) instead of the full block gives
1171
+ that ~150px back to the one thing this screen is actually for right now: the list.
1172
+ */
1173
+ /* @__PURE__ */ jsxs("div", { className: "cv-transfer", children: [
1174
+ /* @__PURE__ */ jsxs("div", { className: "cv-transfer-context", children: [
1175
+ /* @__PURE__ */ jsx("span", { className: "cv-call-devices-title", children: name }),
1176
+ /* @__PURE__ */ jsxs("p", { className: "cv-call-devices-context", children: [
1177
+ showNumber ? `${number} \xB7 ` : "",
1178
+ s === "active" ? `${mm}:${ss}` : STATUS_LABEL[s] ?? ""
1179
+ ] })
1180
+ ] }),
1181
+ consultOutcome ? /* @__PURE__ */ jsx("p", { className: "cv-dial-outcome", role: "status", "data-shown": "true", children: consultFailText(consultOutcome) }) : null,
1182
+ /* @__PURE__ */ jsxs("div", { className: "cv-transfer-row", children: [
1183
+ /* @__PURE__ */ jsx(
1184
+ "input",
1185
+ {
1186
+ className: "cv-search",
1187
+ value: transferTo,
1188
+ onChange: (e) => setTransferTo(e.target.value),
1189
+ placeholder: "Nombre, ext. o n\xFAmero",
1190
+ "aria-label": "Transferir a",
1191
+ autoFocus: true
1192
+ }
1193
+ ),
1194
+ /* @__PURE__ */ jsx(
1195
+ "button",
1196
+ {
1197
+ className: "cv-btn cv-btn--accent",
1198
+ disabled: !transferTo.trim(),
1199
+ onClick: () => void startConsult(),
1200
+ children: transferTo.trim() ? `Llamar a ${truncateForButton(transferTo.trim())}` : "Llamar"
1201
+ }
1202
+ )
1203
+ ] }),
1204
+ /* @__PURE__ */ jsx("div", { className: "cv-segmented", role: "tablist", "aria-label": "Modo de transferencia", children: [
1205
+ { key: "consult", label: "Hablar primero" },
1206
+ { key: "auto", label: "Transferir directo" }
1207
+ ].map(({ key, label }) => /* @__PURE__ */ jsx(
1208
+ "button",
1209
+ {
1210
+ type: "button",
1211
+ role: "tab",
1212
+ "aria-selected": quickTransfer === (key === "auto"),
1213
+ className: "cv-segmented-btn",
1214
+ onClick: () => setQuickTransfer(key === "auto"),
1215
+ children: label
1216
+ },
1217
+ key
1218
+ )) }),
1219
+ /* @__PURE__ */ jsx(
1220
+ TransferPicker,
1221
+ {
1222
+ agents: presence,
1223
+ myUserId,
1224
+ query: transferTo,
1225
+ onSelect: quickTransfer ? selectPresenceAgentAuto : selectPresenceAgent
1226
+ }
1227
+ )
1228
+ ] })
1229
+ ) : /* @__PURE__ */ jsxs(Fragment, { children: [
848
1230
  /* @__PURE__ */ jsxs("div", { className: "cv-avatar-wrap", children: [
849
1231
  isRinging ? /* @__PURE__ */ jsx("span", { className: "cv-avatar-ping", "aria-hidden": true }) : null,
850
1232
  /* @__PURE__ */ jsx("div", { className: "cv-avatar", "aria-hidden": true, children: initials(name) }),
@@ -874,31 +1256,6 @@ function CallScreen({
874
1256
  /* @__PURE__ */ jsx(Headphones, { size: 13, "aria-hidden": true }),
875
1257
  "Dispositivos"
876
1258
  ] })
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
1259
  ] }) : null
903
1260
  ] }) }),
904
1261
  /* @__PURE__ */ jsx("div", { className: "cv-controls", ref: controlsRef, children: isRinging ? /* @__PURE__ */ jsxs("div", { className: "cv-controls-primary", children: [
@@ -1695,117 +2052,6 @@ function MicPermissionGate({ children }) {
1695
2052
  if (state === "ready") return /* @__PURE__ */ jsx(Fragment, { children });
1696
2053
  return /* @__PURE__ */ jsx("div", { className: "cv-softphone", children: /* @__PURE__ */ jsx(MicPrompt, { state, onRequest: () => void request() }) });
1697
2054
  }
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."
1807
- }
1808
- };
1809
2055
  function ErrorGate({
1810
2056
  error,
1811
2057
  onRetry
@@ -2137,6 +2383,94 @@ function useCallAudio(enabled) {
2137
2383
  };
2138
2384
  }, [softphone, enabled]);
2139
2385
  }
2386
+ function TransferNotices({
2387
+ notices,
2388
+ dismiss
2389
+ }) {
2390
+ const softphone = useSoftphone();
2391
+ const now = useTicker(notices.length > 0, 15e3);
2392
+ const callBack = (notice) => {
2393
+ dismiss(notice.id);
2394
+ if (notice.caller) void softphone.call(notice.caller).catch(() => {
2395
+ });
2396
+ };
2397
+ return /* @__PURE__ */ jsxs(
2398
+ "div",
2399
+ {
2400
+ className: "cv-transfer-notices",
2401
+ role: "status",
2402
+ part: "transfer-notices",
2403
+ "data-has-notices": notices.length > 0,
2404
+ children: [
2405
+ notices.length > 1 ? /* @__PURE__ */ jsxs("p", { className: "cv-transfer-notices-count", children: [
2406
+ notices.length,
2407
+ " traspasos sin conectar"
2408
+ ] }) : null,
2409
+ notices.map((notice) => /* @__PURE__ */ jsxs("div", { className: "cv-transfer-notice", part: "transfer-notice", children: [
2410
+ /* @__PURE__ */ jsx("span", { className: "cv-transfer-notice-mark", "aria-hidden": true, children: /* @__PURE__ */ jsx(PhoneMissed, { size: 16 }) }),
2411
+ /* @__PURE__ */ jsxs("div", { className: "cv-transfer-notice-body", children: [
2412
+ /* @__PURE__ */ jsxs("div", { className: "cv-transfer-notice-header", children: [
2413
+ /* @__PURE__ */ jsxs("p", { className: "cv-transfer-notice-title", children: [
2414
+ "El traspaso a ",
2415
+ notice.target,
2416
+ " no se conect\xF3"
2417
+ ] }),
2418
+ /* @__PURE__ */ jsx(
2419
+ "button",
2420
+ {
2421
+ type: "button",
2422
+ className: "cv-transfer-notice-dismiss",
2423
+ onClick: () => dismiss(notice.id),
2424
+ "aria-label": "Descartar aviso",
2425
+ part: "transfer-notice-dismiss",
2426
+ children: /* @__PURE__ */ jsx(X, { size: 14 })
2427
+ }
2428
+ )
2429
+ ] }),
2430
+ notice.caller ? /* @__PURE__ */ jsxs("p", { className: "cv-transfer-notice-subtitle", children: [
2431
+ notice.caller,
2432
+ " se ha desconectado."
2433
+ ] }) : null,
2434
+ /* @__PURE__ */ jsxs("div", { className: "cv-transfer-notice-footer", children: [
2435
+ /* @__PURE__ */ jsx("p", { className: "cv-transfer-notice-age", children: formatAge(elapsedSeconds(notice.at, now)) }),
2436
+ notice.caller ? /* @__PURE__ */ jsxs(
2437
+ "button",
2438
+ {
2439
+ type: "button",
2440
+ className: "cv-btn cv-btn--accent cv-transfer-notice-callback",
2441
+ onClick: () => callBack(notice),
2442
+ part: "transfer-notice-callback",
2443
+ children: [
2444
+ /* @__PURE__ */ jsx(Phone, { size: 14, "aria-hidden": true }),
2445
+ "Llamar de vuelta"
2446
+ ]
2447
+ }
2448
+ ) : null
2449
+ ] })
2450
+ ] })
2451
+ ] }, notice.id))
2452
+ ]
2453
+ }
2454
+ );
2455
+ }
2456
+ function useTransferNotices() {
2457
+ const softphone = useSoftphone();
2458
+ const [notices, setNotices] = useState([]);
2459
+ const nextId = useRef(0);
2460
+ const dismiss = useCallback((id) => {
2461
+ setNotices((prev) => prev.filter((n) => n.id !== id));
2462
+ }, []);
2463
+ useEffect(
2464
+ () => softphone.on("transferOutcome" /* TransferOutcome */, (payload) => {
2465
+ if (payload.status === "not-connected") {
2466
+ const id = nextId.current++;
2467
+ setNotices((prev) => [...prev, { id, target: payload.target, caller: payload.caller, at: payload.at }]);
2468
+ }
2469
+ }),
2470
+ [softphone]
2471
+ );
2472
+ return { notices, dismiss };
2473
+ }
2140
2474
  function SoftphoneWidget(props) {
2141
2475
  const {
2142
2476
  contacts,
@@ -2161,7 +2495,9 @@ function SoftphoneWidget(props) {
2161
2495
  useCallAudio(sounds);
2162
2496
  const blocked = requireMic && mic.state !== "ready";
2163
2497
  const fatal = useLastError();
2498
+ const screenIsFree = !call && !blocked && !fatal && !isFollower;
2164
2499
  const { outcome, clear: clearOutcome } = useCallOutcome();
2500
+ const { notices: transferNotices, dismiss: dismissTransferNotice } = useTransferNotices();
2165
2501
  const retry = () => {
2166
2502
  void (async () => {
2167
2503
  try {
@@ -2185,8 +2521,9 @@ function SoftphoneWidget(props) {
2185
2521
  className: className ? `cv-softphone ${className}` : "cv-softphone",
2186
2522
  ...theme === "system" ? {} : { "data-cv-theme": theme },
2187
2523
  children: [
2524
+ screenIsFree ? /* @__PURE__ */ jsx(TransferNotices, { notices: transferNotices, dismiss: dismissTransferNotice }) : null,
2188
2525
  /* @__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(
2526
+ screenIsFree ? /* @__PURE__ */ jsx("div", { className: "cv-tabs", role: "tablist", children: tabs.map(({ id, icon: Icon3, label }) => /* @__PURE__ */ jsxs(
2190
2527
  "button",
2191
2528
  {
2192
2529
  role: "tab",
@@ -2199,7 +2536,7 @@ function SoftphoneWidget(props) {
2199
2536
  ]
2200
2537
  },
2201
2538
  id
2202
- )) })
2539
+ )) }) : null
2203
2540
  ]
2204
2541
  }
2205
2542
  );