@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.cjs CHANGED
@@ -13418,6 +13418,20 @@ function useActiveCall() {
13418
13418
  React.useCallback(() => phone.activeCall, [phone])
13419
13419
  );
13420
13420
  }
13421
+ function usePresence(active) {
13422
+ const phone = useSoftphone();
13423
+ return React.useSyncExternalStore(
13424
+ React.useCallback(
13425
+ (cb) => {
13426
+ if (!active) return () => {
13427
+ };
13428
+ return phone.on("presenceChanged" /* PresenceChanged */, () => cb());
13429
+ },
13430
+ [phone, active]
13431
+ ),
13432
+ React.useCallback(() => phone.presence, [phone])
13433
+ );
13434
+ }
13421
13435
  var CALL_EVENTS = [
13422
13436
  "stateChanged" /* StateChanged */,
13423
13437
  "muted" /* Muted */,
@@ -13836,6 +13850,125 @@ function elapsedSeconds(since, now) {
13836
13850
  if (!since) return 0;
13837
13851
  return Math.floor((now - since.getTime()) / 1e3);
13838
13852
  }
13853
+ function formatAge(totalSeconds) {
13854
+ const s = Math.max(0, Math.floor(totalSeconds));
13855
+ if (s < 60) return "justo ahora";
13856
+ const mm = Math.floor(s / 60);
13857
+ if (mm < 60) return `hace ${mm} min`;
13858
+ const hh = Math.floor(mm / 60);
13859
+ return `hace ${hh} h`;
13860
+ }
13861
+
13862
+ // src/ui/lib/error-text.ts
13863
+ var ERROR_TEXT = {
13864
+ // ── Engine lifecycle ────────────────────────────────────────────────────────
13865
+ // Integrator mistakes rather than user-facing failures, but they must still read as something
13866
+ // rather than as a blank panel if one ever reaches the UI.
13867
+ "engine/duplicate-instance": {
13868
+ title: "Tel\xE9fono duplicado",
13869
+ body: "Hay otro tel\xE9fono abierto en esta p\xE1gina. Cierra uno y vuelve a cargar."
13870
+ },
13871
+ "engine/not-started": { title: "El tel\xE9fono a\xFAn no est\xE1 listo" },
13872
+ "engine/destroyed": {
13873
+ title: "Tel\xE9fono cerrado",
13874
+ body: "Vuelve a cargar la p\xE1gina para usarlo de nuevo."
13875
+ },
13876
+ // ── Auth ────────────────────────────────────────────────────────────────────
13877
+ // Never "token": the user has no idea what one is. What they can act on is the session.
13878
+ "auth/token-fetch-failed": {
13879
+ title: "Sin conexi\xF3n con el servidor",
13880
+ body: "No se ha podido activar el tel\xE9fono. Reint\xE9ntalo en unos segundos."
13881
+ },
13882
+ "auth/token-rejected": {
13883
+ title: "Sesi\xF3n caducada",
13884
+ body: "Vuelve a iniciar sesi\xF3n para poder llamar."
13885
+ },
13886
+ // ── Media ───────────────────────────────────────────────────────────────────
13887
+ // Verbatim from MicPermissionGate, which already says these well and says them first.
13888
+ //
13889
+ // NEVER ACTUALLY SHOWN via this table — these four codes (through `media/playback-blocked`, below)
13890
+ // are never raised as a `SoftphoneError` at all. See the fuller comment on each in `core/errors.ts`
13891
+ // for why. Copy kept here anyway so the catalogue and this file stay the same shape as every other
13892
+ // code — `error-text.test.ts` would fail the build the moment they drifted.
13893
+ "media/permission-denied": {
13894
+ title: "Micr\xF3fono bloqueado",
13895
+ body: "Perm\xEDtelo en tu navegador para poder llamar."
13896
+ },
13897
+ "media/no-input-device": {
13898
+ title: "Sin micr\xF3fono",
13899
+ body: "Conecta un micr\xF3fono y vuelve a intentarlo."
13900
+ },
13901
+ "media/webrtc-unsupported": {
13902
+ title: "Llamadas no disponibles",
13903
+ body: "Este navegador no admite llamadas. Prueba con Chrome, Edge o Firefox."
13904
+ },
13905
+ "media/playback-blocked": { title: "Toca la pantalla para activar el sonido" },
13906
+ // ── Network / signalling ────────────────────────────────────────────────────
13907
+ // "Centralita" rather than "SIP" or "registro": the user knows what a phone system is.
13908
+ "net/registration-failed": {
13909
+ title: "No se pudo conectar",
13910
+ body: "No hemos podido conectar con la centralita. Reintenta en unos segundos."
13911
+ },
13912
+ "net/transport-failed": {
13913
+ title: "Conexi\xF3n perdida",
13914
+ body: "Se ha perdido la conexi\xF3n con la centralita."
13915
+ },
13916
+ // NEVER ACTUALLY SHOWN either — CallScreen's own "Reconectando…" already covers this, off the
13917
+ // call's `reconnecting` state rather than this channel. See the comment in `core/errors.ts`.
13918
+ "net/ice-failed": { title: "Recuperando el audio\u2026" },
13919
+ // ── Call ────────────────────────────────────────────────────────────────────
13920
+ /*
13921
+ THE VOICE RULE FOR THIS BLOCK: say what HAPPENED, not what it is called.
13922
+
13923
+ These were status labels — "Llamada perdida", "Llamada rechazada", "Sin respuesta". Each is a
13924
+ name for a category rather than an account of an event, which is why they read as a database enum
13925
+ someone forgot to translate into human. Worse, a label hides the ACTOR: "Llamada rechazada" never
13926
+ says who rejected what, so the user reading it after declining a call and the user reading it
13927
+ after being declined see the same three words.
13928
+
13929
+ So each one is a short sentence about what the other person, or the phone, did — the form
13930
+ `call/busy` already had, and the reason it was the only one here that read clearly.
13931
+
13932
+ Impersonal third-person plural ("contestaron", "colgaron") throughout: the user does not know who
13933
+ is at the other end, and it is what Spanish uses when the subject is unknown. Mixing it with the
13934
+ singular across neighbouring lines reads as carelessness.
13935
+
13936
+ LENGTH IS A HARD CONSTRAINT, not a preference. `.cv-dial-outcome` is one line at a fixed
13937
+ `1.15rem`, and the keypad below it is `flex: 1` — a title that wraps resizes every key and shifts
13938
+ the grid under a finger already reaching for it. `error-text.test.ts` caps this at 48 characters.
13939
+ */
13940
+ "call/not-answerable": { title: "Esa llamada ya ha terminado" },
13941
+ /*
13942
+ NOT "Comunicando", which is what this said and what sent us looking. SIP 486 is both "the line is
13943
+ engaged" AND what almost every endpoint sends when a person presses decline — including our own
13944
+ `reject()`. One code, two events, no way to tell them apart, so the copy has to hold both rather
13945
+ than assert the less likely one. `call/rejected` can be definite: an endpoint sending 603 Decline
13946
+ means exactly that.
13947
+ */
13948
+ "call/busy": { title: "No contestaron o la l\xEDnea estaba ocupada" },
13949
+ "call/no-answer": { title: "No contestaron" },
13950
+ "call/rejected": { title: "No aceptaron tu llamada" },
13951
+ /*
13952
+ The legacy CRM said "La persona que llamaba colgó antes de que pudieras contestar." — the right
13953
+ fact, at three times the width of the line that has to hold it. This keeps the fact and drops the
13954
+ words: the user needs to know the call was FOR them and that nobody is waiting on the line.
13955
+ "Llamada perdida" alone tested badly for exactly the label reason above — it names the category
13956
+ without saying anyone rang.
13957
+ */
13958
+ "call/missed": { title: "Colgaron antes de que pudieras contestar" },
13959
+ // "No se pudo completar la llamada" was our own "something went wrong": true of every failure and
13960
+ // useful for none. The number is what the user can actually act on.
13961
+ "call/failed": { title: "No se pudo conectar con ese n\xFAmero" },
13962
+ // The load-bearing half is the SECOND sentence: the original call is still up, and a user who
13963
+ // thinks they dropped it will hang up on a client to "fix" it.
13964
+ "call/transfer-failed": { title: "No se pudo transferir. Sigues en la llamada" },
13965
+ "call/unsupported": { title: "Esta funci\xF3n no est\xE1 disponible" },
13966
+ // ── Provider ────────────────────────────────────────────────────────────────
13967
+ "provider/provision-failed": {
13968
+ title: "No se pudo activar el tel\xE9fono",
13969
+ body: "Si el problema contin\xFAa, contacta con soporte."
13970
+ }
13971
+ };
13839
13972
  function useTicker(enabled, intervalMs = 1e3) {
13840
13973
  const [now, setNow] = React.useState(() => Date.now());
13841
13974
  React.useEffect(() => {
@@ -13948,17 +14081,195 @@ var STATUS_LABEL = {
13948
14081
  reconnecting: "Reconectando\u2026",
13949
14082
  held: "En espera"
13950
14083
  };
14084
+ var CONSULT_FAIL_CODE = {
14085
+ busy: "call/busy",
14086
+ "no-answer": "call/no-answer",
14087
+ rejected: "call/rejected",
14088
+ failed: "call/failed"
14089
+ };
14090
+ function consultFailText(reason) {
14091
+ return ERROR_TEXT[CONSULT_FAIL_CODE[reason] ?? "call/transfer-failed"].title;
14092
+ }
14093
+ var MIN_CONSULT_HOLD_SECONDS = 10;
14094
+ function ConsultScreen({
14095
+ mainName,
14096
+ consultView,
14097
+ now,
14098
+ onReturn,
14099
+ onComplete,
14100
+ onHangupAll
14101
+ }) {
14102
+ const s = consultView.state;
14103
+ const answered = s === "active";
14104
+ const name = consultView.remote.displayName;
14105
+ const number = consultView.remote.number;
14106
+ const showNumber = number !== name;
14107
+ const heldSeconds = elapsedSeconds(consultView.answeredAt, now);
14108
+ const [rawM = "0", ss = "00"] = formatDuration(heldSeconds).split(":");
14109
+ const mm = rawM.padStart(2, "0");
14110
+ const canComplete = heldSeconds >= MIN_CONSULT_HOLD_SECONDS;
14111
+ return /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "cv-view", children: [
14112
+ /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "cv-call", children: [
14113
+ /* @__PURE__ */ jsxRuntime.jsxs("button", { type: "button", className: "cv-consult-hold-banner", onClick: onReturn, children: [
14114
+ /* @__PURE__ */ jsxRuntime.jsx(Pause, { size: 13, "aria-hidden": true }),
14115
+ mainName,
14116
+ " en espera \xB7 Volver"
14117
+ ] }),
14118
+ /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "cv-avatar-wrap", children: [
14119
+ /* @__PURE__ */ jsxRuntime.jsx("div", { className: "cv-avatar", "aria-hidden": true, children: initials(name) }),
14120
+ answered ? /* @__PURE__ */ jsxRuntime.jsx("span", { className: "cv-avatar-dot", "aria-hidden": true }) : null
14121
+ ] }),
14122
+ /* @__PURE__ */ jsxRuntime.jsx("div", { className: "cv-call-name", children: name }),
14123
+ showNumber ? /* @__PURE__ */ jsxRuntime.jsx("div", { className: "cv-call-number", children: number }) : null,
14124
+ answered ? /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "cv-timer", "aria-label": "Duraci\xF3n de la llamada", children: [
14125
+ /* @__PURE__ */ jsxRuntime.jsx("b", { children: mm }),
14126
+ /* @__PURE__ */ jsxRuntime.jsx("i", { children: ":" }),
14127
+ /* @__PURE__ */ jsxRuntime.jsx("b", { children: ss })
14128
+ ] }) : /* @__PURE__ */ jsxRuntime.jsx("div", { className: "cv-call-status", children: STATUS_LABEL[s] ?? "" })
14129
+ ] }),
14130
+ /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "cv-controls", children: [
14131
+ /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "cv-transfer-actions", children: [
14132
+ answered ? /* @__PURE__ */ jsxRuntime.jsx("button", { className: "cv-btn cv-btn--accent", onClick: onComplete, disabled: !canComplete, children: "Completar" }) : null,
14133
+ /* @__PURE__ */ jsxRuntime.jsx("button", { className: "cv-btn", onClick: onReturn, children: "Cancelar" })
14134
+ ] }),
14135
+ /* @__PURE__ */ jsxRuntime.jsx("div", { className: "cv-controls-primary", children: /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "cv-ctrl-item", children: [
14136
+ /* @__PURE__ */ jsxRuntime.jsx(
14137
+ "button",
14138
+ {
14139
+ type: "button",
14140
+ className: "cv-round cv-round--hangup",
14141
+ onClick: onHangupAll,
14142
+ "aria-label": "Colgar todo",
14143
+ children: /* @__PURE__ */ jsxRuntime.jsx(PhoneOff, { size: 26 })
14144
+ }
14145
+ ),
14146
+ /* @__PURE__ */ jsxRuntime.jsx("span", { className: "cv-ctrl-label", "aria-hidden": true, children: "Colgar todo" })
14147
+ ] }) })
14148
+ ] })
14149
+ ] });
14150
+ }
14151
+ var PRESENCE_LABEL = {
14152
+ disponible: "Disponible",
14153
+ en_llamada: "En llamada",
14154
+ desconectado: "Desconectado"
14155
+ };
14156
+ var PRESENCE_RANK = {
14157
+ disponible: 0,
14158
+ en_llamada: 1,
14159
+ desconectado: 2
14160
+ };
14161
+ function filterAgents(agents, query) {
14162
+ const q = query.trim().toLowerCase();
14163
+ return agents.filter((a) => !q || (a.name ?? "").toLowerCase().includes(q) || a.extension.includes(q)).sort(
14164
+ (a, b) => PRESENCE_RANK[a.state] - PRESENCE_RANK[b.state] || (a.name ?? a.extension).localeCompare(b.name ?? b.extension)
14165
+ );
14166
+ }
14167
+ function truncateForButton(text, max = 16) {
14168
+ return text.length > max ? `${text.slice(0, max)}\u2026` : text;
14169
+ }
14170
+ function TransferPicker({
14171
+ agents,
14172
+ myUserId,
14173
+ query,
14174
+ onSelect
14175
+ }) {
14176
+ const others = agents.filter((a) => a.userId !== myUserId);
14177
+ if (others.length === 0) return null;
14178
+ const filtered = filterAgents(others, query);
14179
+ return /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "cv-presence", children: [
14180
+ /* @__PURE__ */ jsxRuntime.jsx("div", { className: "cv-presence-list", role: "listbox", "aria-label": "Compa\xF1eros", children: filtered.length === 0 ? /* @__PURE__ */ jsxRuntime.jsx("p", { className: "cv-presence-empty", children: "Sin coincidencias" }) : filtered.map((agent) => {
14181
+ const isBusy = agent.state === "en_llamada";
14182
+ return /* @__PURE__ */ jsxRuntime.jsxs(
14183
+ "button",
14184
+ {
14185
+ type: "button",
14186
+ role: "option",
14187
+ className: "cv-presence-item",
14188
+ disabled: isBusy,
14189
+ onClick: () => onSelect(agent.extension),
14190
+ children: [
14191
+ /* @__PURE__ */ jsxRuntime.jsx("span", { className: "cv-presence-dot", "data-state": agent.state, "aria-hidden": true }),
14192
+ /* @__PURE__ */ jsxRuntime.jsx("span", { className: "cv-presence-name", children: agent.name ?? agent.extension }),
14193
+ /* @__PURE__ */ jsxRuntime.jsxs("span", { className: "cv-presence-meta", children: [
14194
+ agent.extension,
14195
+ " \xB7 ",
14196
+ PRESENCE_LABEL[agent.state]
14197
+ ] })
14198
+ ]
14199
+ },
14200
+ agent.userId
14201
+ );
14202
+ }) }),
14203
+ /* @__PURE__ */ jsxRuntime.jsx("p", { className: "cv-presence-legend", children: "Desconectado: sin conexi\xF3n en el softphone web. Puede atender desde otro dispositivo." })
14204
+ ] });
14205
+ }
13951
14206
  function CallScreen({
13952
14207
  call,
13953
14208
  incomingCallAction
13954
14209
  }) {
13955
14210
  const view = useCall(call);
14211
+ const softphone = useSoftphone();
13956
14212
  const [showTransfer, setShowTransfer] = React.useState(false);
13957
14213
  const [transferTo, setTransferTo] = React.useState("");
14214
+ const [consultCall, setConsultCall] = React.useState(null);
14215
+ const [consultOutcome, setConsultOutcome] = React.useState(null);
14216
+ const [myUserId, setMyUserId] = React.useState(null);
14217
+ const consultView = useCall(consultCall);
14218
+ const consultIntentRef = React.useRef(null);
13958
14219
  const [showDevices, setShowDevices] = React.useState(false);
13959
14220
  const controlsRef = React.useRef(null);
13960
14221
  const now = useTicker(view?.state === "active");
14222
+ const consultNow = useTicker(consultView?.state === "active");
13961
14223
  const isConnected = view ? view.state === "active" || view.state === "held" || view.state === "reconnecting" : false;
14224
+ const transferPickerActive = isConnected && showTransfer && !consultCall;
14225
+ const presence = usePresence(transferPickerActive);
14226
+ React.useEffect(() => {
14227
+ const listAgents = softphone.sources?.listAgents;
14228
+ if (!listAgents) return;
14229
+ let cancelled = false;
14230
+ void listAgents().then((d) => !cancelled && setMyUserId(d.me?.userId ?? null)).catch(() => {
14231
+ });
14232
+ return () => {
14233
+ cancelled = true;
14234
+ };
14235
+ }, [softphone]);
14236
+ React.useEffect(() => {
14237
+ if (!consultCall) return;
14238
+ return consultCall.on("ended" /* Ended */, ({ reason }) => {
14239
+ const intent = consultIntentRef.current;
14240
+ consultIntentRef.current = null;
14241
+ setConsultCall(null);
14242
+ if (!intent) {
14243
+ setConsultOutcome(reason);
14244
+ if (call.state !== "ended") void call.unhold();
14245
+ }
14246
+ });
14247
+ }, [consultCall, call]);
14248
+ const startConsult = async (target) => {
14249
+ const t = (target ?? transferTo).trim();
14250
+ if (!t) return;
14251
+ setConsultOutcome(null);
14252
+ setConsultCall(await call.startConsult(t));
14253
+ };
14254
+ const selectPresenceAgent = (extension) => {
14255
+ setTransferTo(extension);
14256
+ void startConsult(extension);
14257
+ };
14258
+ const [quickTransfer, setQuickTransfer] = React.useState(false);
14259
+ const selectPresenceAgentAuto = (extension) => {
14260
+ void call.transfer(extension);
14261
+ };
14262
+ const completeTransfer = async () => {
14263
+ if (!consultCall) return;
14264
+ const target = transferTo.trim();
14265
+ consultIntentRef.current = "complete";
14266
+ await consultCall.hangup();
14267
+ await call.transfer(target);
14268
+ };
14269
+ const cancelConsult = async () => {
14270
+ consultIntentRef.current = "cancel";
14271
+ await call.cancelConsult();
14272
+ };
13962
14273
  React.useEffect(() => {
13963
14274
  if (!showDevices) return;
13964
14275
  const onKey = (e) => {
@@ -13983,6 +14294,19 @@ function CallScreen({
13983
14294
  const name = view.remote.displayName;
13984
14295
  const number = view.remote.number;
13985
14296
  const showNumber = number !== name;
14297
+ if (consultCall && consultView) {
14298
+ return /* @__PURE__ */ jsxRuntime.jsx(
14299
+ ConsultScreen,
14300
+ {
14301
+ mainName: name,
14302
+ consultView,
14303
+ now: consultNow,
14304
+ onReturn: () => void cancelConsult(),
14305
+ onComplete: () => void completeTransfer(),
14306
+ onHangupAll: () => void call.hangup()
14307
+ }
14308
+ );
14309
+ }
13986
14310
  const incomingAction = isRinging && view.remote.contact && incomingCallAction ? incomingCallAction : null;
13987
14311
  const IncomingActionIcon = incomingAction?.icon ?? ExternalLink;
13988
14312
  const [rawM = "0", ss = "00"] = formatDuration(elapsedSeconds(view.answeredAt, now)).split(":");
@@ -14001,7 +14325,79 @@ function CallScreen({
14001
14325
  STATUS_LABEL[s] ?? ""
14002
14326
  ] }),
14003
14327
  /* @__PURE__ */ jsxRuntime.jsx("div", { className: "cv-settings-section-body", children: /* @__PURE__ */ jsxRuntime.jsx(DevicePickers, {}) })
14004
- ] }) : /* @__PURE__ */ jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [
14328
+ ] }) : transferPickerActive ? (
14329
+ /*
14330
+ Found live (2026-09-29): the agent list a moment ago picked between showing ONE agent row
14331
+ or forcing a scroll — `.cv-presence-list`'s own `max-height` had already been trimmed twice
14332
+ (T7, then T3) chasing that, and both times the room it was chasing lived somewhere else.
14333
+ The full avatar/name/giant-timer block above stayed on screen, unconditionally, the WHOLE
14334
+ time the picker was open — exactly the anti-pattern `.cv-call-devices-inline` (above) was
14335
+ already built to avoid for the pre-connect device picker: "an inline swap, not an overlay,
14336
+ so it gets the full box, not a leftover sliver." The picker never got that same treatment.
14337
+ Measured live (a temporary harness, `playground/react/picker-harness.*`, real Chromium at
14338
+ this SDK's own real embed footprint, 310×520): the avatar+name+timer block alone cost
14339
+ ~150px of `.cv-call`'s ~340px of usable height — more than the entire agent list, the mode
14340
+ toggle, and the free-text row combined. Reusing `.cv-call-devices-inline`'s OWN "who's
14341
+ calling doesn't disappear" compact context line (below) instead of the full block gives
14342
+ that ~150px back to the one thing this screen is actually for right now: the list.
14343
+ */
14344
+ /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "cv-transfer", children: [
14345
+ /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "cv-transfer-context", children: [
14346
+ /* @__PURE__ */ jsxRuntime.jsx("span", { className: "cv-call-devices-title", children: name }),
14347
+ /* @__PURE__ */ jsxRuntime.jsxs("p", { className: "cv-call-devices-context", children: [
14348
+ showNumber ? `${number} \xB7 ` : "",
14349
+ s === "active" ? `${mm}:${ss}` : STATUS_LABEL[s] ?? ""
14350
+ ] })
14351
+ ] }),
14352
+ consultOutcome ? /* @__PURE__ */ jsxRuntime.jsx("p", { className: "cv-dial-outcome", role: "status", "data-shown": "true", children: consultFailText(consultOutcome) }) : null,
14353
+ /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "cv-transfer-row", children: [
14354
+ /* @__PURE__ */ jsxRuntime.jsx(
14355
+ "input",
14356
+ {
14357
+ className: "cv-search",
14358
+ value: transferTo,
14359
+ onChange: (e) => setTransferTo(e.target.value),
14360
+ placeholder: "Nombre, ext. o n\xFAmero",
14361
+ "aria-label": "Transferir a",
14362
+ autoFocus: true
14363
+ }
14364
+ ),
14365
+ /* @__PURE__ */ jsxRuntime.jsx(
14366
+ "button",
14367
+ {
14368
+ className: "cv-btn cv-btn--accent",
14369
+ disabled: !transferTo.trim(),
14370
+ onClick: () => void startConsult(),
14371
+ children: transferTo.trim() ? `Llamar a ${truncateForButton(transferTo.trim())}` : "Llamar"
14372
+ }
14373
+ )
14374
+ ] }),
14375
+ /* @__PURE__ */ jsxRuntime.jsx("div", { className: "cv-segmented", role: "tablist", "aria-label": "Modo de transferencia", children: [
14376
+ { key: "consult", label: "Hablar primero" },
14377
+ { key: "auto", label: "Transferir directo" }
14378
+ ].map(({ key, label }) => /* @__PURE__ */ jsxRuntime.jsx(
14379
+ "button",
14380
+ {
14381
+ type: "button",
14382
+ role: "tab",
14383
+ "aria-selected": quickTransfer === (key === "auto"),
14384
+ className: "cv-segmented-btn",
14385
+ onClick: () => setQuickTransfer(key === "auto"),
14386
+ children: label
14387
+ },
14388
+ key
14389
+ )) }),
14390
+ /* @__PURE__ */ jsxRuntime.jsx(
14391
+ TransferPicker,
14392
+ {
14393
+ agents: presence,
14394
+ myUserId,
14395
+ query: transferTo,
14396
+ onSelect: quickTransfer ? selectPresenceAgentAuto : selectPresenceAgent
14397
+ }
14398
+ )
14399
+ ] })
14400
+ ) : /* @__PURE__ */ jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [
14005
14401
  /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "cv-avatar-wrap", children: [
14006
14402
  isRinging ? /* @__PURE__ */ jsxRuntime.jsx("span", { className: "cv-avatar-ping", "aria-hidden": true }) : null,
14007
14403
  /* @__PURE__ */ jsxRuntime.jsx("div", { className: "cv-avatar", "aria-hidden": true, children: initials(name) }),
@@ -14031,31 +14427,6 @@ function CallScreen({
14031
14427
  /* @__PURE__ */ jsxRuntime.jsx(Headphones, { size: 13, "aria-hidden": true }),
14032
14428
  "Dispositivos"
14033
14429
  ] })
14034
- ] }) : null,
14035
- inCall && showTransfer ? /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "cv-transfer", children: [
14036
- /* @__PURE__ */ jsxRuntime.jsx(
14037
- "input",
14038
- {
14039
- className: "cv-search",
14040
- value: transferTo,
14041
- onChange: (e) => setTransferTo(e.target.value),
14042
- placeholder: "Extensi\xF3n o n\xFAmero",
14043
- "aria-label": "Transferir a",
14044
- autoFocus: true
14045
- }
14046
- ),
14047
- /* @__PURE__ */ jsxRuntime.jsx(
14048
- "button",
14049
- {
14050
- className: "cv-btn cv-btn--accent",
14051
- disabled: !transferTo.trim(),
14052
- onClick: () => {
14053
- void call.transfer(transferTo.trim());
14054
- setShowTransfer(false);
14055
- },
14056
- children: "Transferir"
14057
- }
14058
- )
14059
14430
  ] }) : null
14060
14431
  ] }) }),
14061
14432
  /* @__PURE__ */ jsxRuntime.jsx("div", { className: "cv-controls", ref: controlsRef, children: isRinging ? /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "cv-controls-primary", children: [
@@ -14862,117 +15233,6 @@ function MicPermissionGate({ children }) {
14862
15233
  if (state === "ready") return /* @__PURE__ */ jsxRuntime.jsx(jsxRuntime.Fragment, { children });
14863
15234
  return /* @__PURE__ */ jsxRuntime.jsx("div", { className: "cv-softphone", children: /* @__PURE__ */ jsxRuntime.jsx(MicPrompt, { state, onRequest: () => void request() }) });
14864
15235
  }
14865
-
14866
- // src/ui/lib/error-text.ts
14867
- var ERROR_TEXT = {
14868
- // ── Engine lifecycle ────────────────────────────────────────────────────────
14869
- // Integrator mistakes rather than user-facing failures, but they must still read as something
14870
- // rather than as a blank panel if one ever reaches the UI.
14871
- "engine/duplicate-instance": {
14872
- title: "Tel\xE9fono duplicado",
14873
- body: "Hay otro tel\xE9fono abierto en esta p\xE1gina. Cierra uno y vuelve a cargar."
14874
- },
14875
- "engine/not-started": { title: "El tel\xE9fono a\xFAn no est\xE1 listo" },
14876
- "engine/destroyed": {
14877
- title: "Tel\xE9fono cerrado",
14878
- body: "Vuelve a cargar la p\xE1gina para usarlo de nuevo."
14879
- },
14880
- // ── Auth ────────────────────────────────────────────────────────────────────
14881
- // Never "token": the user has no idea what one is. What they can act on is the session.
14882
- "auth/token-fetch-failed": {
14883
- title: "Sin conexi\xF3n con el servidor",
14884
- body: "No se ha podido activar el tel\xE9fono. Reint\xE9ntalo en unos segundos."
14885
- },
14886
- "auth/token-rejected": {
14887
- title: "Sesi\xF3n caducada",
14888
- body: "Vuelve a iniciar sesi\xF3n para poder llamar."
14889
- },
14890
- // ── Media ───────────────────────────────────────────────────────────────────
14891
- // Verbatim from MicPermissionGate, which already says these well and says them first.
14892
- //
14893
- // NEVER ACTUALLY SHOWN via this table — these four codes (through `media/playback-blocked`, below)
14894
- // are never raised as a `SoftphoneError` at all. See the fuller comment on each in `core/errors.ts`
14895
- // for why. Copy kept here anyway so the catalogue and this file stay the same shape as every other
14896
- // code — `error-text.test.ts` would fail the build the moment they drifted.
14897
- "media/permission-denied": {
14898
- title: "Micr\xF3fono bloqueado",
14899
- body: "Perm\xEDtelo en tu navegador para poder llamar."
14900
- },
14901
- "media/no-input-device": {
14902
- title: "Sin micr\xF3fono",
14903
- body: "Conecta un micr\xF3fono y vuelve a intentarlo."
14904
- },
14905
- "media/webrtc-unsupported": {
14906
- title: "Llamadas no disponibles",
14907
- body: "Este navegador no admite llamadas. Prueba con Chrome, Edge o Firefox."
14908
- },
14909
- "media/playback-blocked": { title: "Toca la pantalla para activar el sonido" },
14910
- // ── Network / signalling ────────────────────────────────────────────────────
14911
- // "Centralita" rather than "SIP" or "registro": the user knows what a phone system is.
14912
- "net/registration-failed": {
14913
- title: "No se pudo conectar",
14914
- body: "No hemos podido conectar con la centralita. Reintenta en unos segundos."
14915
- },
14916
- "net/transport-failed": {
14917
- title: "Conexi\xF3n perdida",
14918
- body: "Se ha perdido la conexi\xF3n con la centralita."
14919
- },
14920
- // NEVER ACTUALLY SHOWN either — CallScreen's own "Reconectando…" already covers this, off the
14921
- // call's `reconnecting` state rather than this channel. See the comment in `core/errors.ts`.
14922
- "net/ice-failed": { title: "Recuperando el audio\u2026" },
14923
- // ── Call ────────────────────────────────────────────────────────────────────
14924
- /*
14925
- THE VOICE RULE FOR THIS BLOCK: say what HAPPENED, not what it is called.
14926
-
14927
- These were status labels — "Llamada perdida", "Llamada rechazada", "Sin respuesta". Each is a
14928
- name for a category rather than an account of an event, which is why they read as a database enum
14929
- someone forgot to translate into human. Worse, a label hides the ACTOR: "Llamada rechazada" never
14930
- says who rejected what, so the user reading it after declining a call and the user reading it
14931
- after being declined see the same three words.
14932
-
14933
- So each one is a short sentence about what the other person, or the phone, did — the form
14934
- `call/busy` already had, and the reason it was the only one here that read clearly.
14935
-
14936
- Impersonal third-person plural ("contestaron", "colgaron") throughout: the user does not know who
14937
- is at the other end, and it is what Spanish uses when the subject is unknown. Mixing it with the
14938
- singular across neighbouring lines reads as carelessness.
14939
-
14940
- LENGTH IS A HARD CONSTRAINT, not a preference. `.cv-dial-outcome` is one line at a fixed
14941
- `1.15rem`, and the keypad below it is `flex: 1` — a title that wraps resizes every key and shifts
14942
- the grid under a finger already reaching for it. `error-text.test.ts` caps this at 48 characters.
14943
- */
14944
- "call/not-answerable": { title: "Esa llamada ya ha terminado" },
14945
- /*
14946
- NOT "Comunicando", which is what this said and what sent us looking. SIP 486 is both "the line is
14947
- engaged" AND what almost every endpoint sends when a person presses decline — including our own
14948
- `reject()`. One code, two events, no way to tell them apart, so the copy has to hold both rather
14949
- than assert the less likely one. `call/rejected` can be definite: an endpoint sending 603 Decline
14950
- means exactly that.
14951
- */
14952
- "call/busy": { title: "No contestaron o la l\xEDnea estaba ocupada" },
14953
- "call/no-answer": { title: "No contestaron" },
14954
- "call/rejected": { title: "No aceptaron tu llamada" },
14955
- /*
14956
- The legacy CRM said "La persona que llamaba colgó antes de que pudieras contestar." — the right
14957
- fact, at three times the width of the line that has to hold it. This keeps the fact and drops the
14958
- words: the user needs to know the call was FOR them and that nobody is waiting on the line.
14959
- "Llamada perdida" alone tested badly for exactly the label reason above — it names the category
14960
- without saying anyone rang.
14961
- */
14962
- "call/missed": { title: "Colgaron antes de que pudieras contestar" },
14963
- // "No se pudo completar la llamada" was our own "something went wrong": true of every failure and
14964
- // useful for none. The number is what the user can actually act on.
14965
- "call/failed": { title: "No se pudo conectar con ese n\xFAmero" },
14966
- // The load-bearing half is the SECOND sentence: the original call is still up, and a user who
14967
- // thinks they dropped it will hang up on a client to "fix" it.
14968
- "call/transfer-failed": { title: "No se pudo transferir. Sigues en la llamada" },
14969
- "call/unsupported": { title: "Esta funci\xF3n no est\xE1 disponible" },
14970
- // ── Provider ────────────────────────────────────────────────────────────────
14971
- "provider/provision-failed": {
14972
- title: "No se pudo activar el tel\xE9fono",
14973
- body: "Si el problema contin\xFAa, contacta con soporte."
14974
- }
14975
- };
14976
15236
  function ErrorGate({
14977
15237
  error,
14978
15238
  onRetry
@@ -15304,6 +15564,94 @@ function useCallAudio(enabled) {
15304
15564
  };
15305
15565
  }, [softphone, enabled]);
15306
15566
  }
15567
+ function TransferNotices({
15568
+ notices,
15569
+ dismiss
15570
+ }) {
15571
+ const softphone = useSoftphone();
15572
+ const now = useTicker(notices.length > 0, 15e3);
15573
+ const callBack = (notice) => {
15574
+ dismiss(notice.id);
15575
+ if (notice.caller) void softphone.call(notice.caller).catch(() => {
15576
+ });
15577
+ };
15578
+ return /* @__PURE__ */ jsxRuntime.jsxs(
15579
+ "div",
15580
+ {
15581
+ className: "cv-transfer-notices",
15582
+ role: "status",
15583
+ part: "transfer-notices",
15584
+ "data-has-notices": notices.length > 0,
15585
+ children: [
15586
+ notices.length > 1 ? /* @__PURE__ */ jsxRuntime.jsxs("p", { className: "cv-transfer-notices-count", children: [
15587
+ notices.length,
15588
+ " traspasos sin conectar"
15589
+ ] }) : null,
15590
+ notices.map((notice) => /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "cv-transfer-notice", part: "transfer-notice", children: [
15591
+ /* @__PURE__ */ jsxRuntime.jsx("span", { className: "cv-transfer-notice-mark", "aria-hidden": true, children: /* @__PURE__ */ jsxRuntime.jsx(PhoneMissed, { size: 16 }) }),
15592
+ /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "cv-transfer-notice-body", children: [
15593
+ /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "cv-transfer-notice-header", children: [
15594
+ /* @__PURE__ */ jsxRuntime.jsxs("p", { className: "cv-transfer-notice-title", children: [
15595
+ "El traspaso a ",
15596
+ notice.target,
15597
+ " no se conect\xF3"
15598
+ ] }),
15599
+ /* @__PURE__ */ jsxRuntime.jsx(
15600
+ "button",
15601
+ {
15602
+ type: "button",
15603
+ className: "cv-transfer-notice-dismiss",
15604
+ onClick: () => dismiss(notice.id),
15605
+ "aria-label": "Descartar aviso",
15606
+ part: "transfer-notice-dismiss",
15607
+ children: /* @__PURE__ */ jsxRuntime.jsx(X, { size: 14 })
15608
+ }
15609
+ )
15610
+ ] }),
15611
+ notice.caller ? /* @__PURE__ */ jsxRuntime.jsxs("p", { className: "cv-transfer-notice-subtitle", children: [
15612
+ notice.caller,
15613
+ " se ha desconectado."
15614
+ ] }) : null,
15615
+ /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "cv-transfer-notice-footer", children: [
15616
+ /* @__PURE__ */ jsxRuntime.jsx("p", { className: "cv-transfer-notice-age", children: formatAge(elapsedSeconds(notice.at, now)) }),
15617
+ notice.caller ? /* @__PURE__ */ jsxRuntime.jsxs(
15618
+ "button",
15619
+ {
15620
+ type: "button",
15621
+ className: "cv-btn cv-btn--accent cv-transfer-notice-callback",
15622
+ onClick: () => callBack(notice),
15623
+ part: "transfer-notice-callback",
15624
+ children: [
15625
+ /* @__PURE__ */ jsxRuntime.jsx(Phone, { size: 14, "aria-hidden": true }),
15626
+ "Llamar de vuelta"
15627
+ ]
15628
+ }
15629
+ ) : null
15630
+ ] })
15631
+ ] })
15632
+ ] }, notice.id))
15633
+ ]
15634
+ }
15635
+ );
15636
+ }
15637
+ function useTransferNotices() {
15638
+ const softphone = useSoftphone();
15639
+ const [notices, setNotices] = React.useState([]);
15640
+ const nextId = React.useRef(0);
15641
+ const dismiss = React.useCallback((id) => {
15642
+ setNotices((prev) => prev.filter((n) => n.id !== id));
15643
+ }, []);
15644
+ React.useEffect(
15645
+ () => softphone.on("transferOutcome" /* TransferOutcome */, (payload) => {
15646
+ if (payload.status === "not-connected") {
15647
+ const id = nextId.current++;
15648
+ setNotices((prev) => [...prev, { id, target: payload.target, caller: payload.caller, at: payload.at }]);
15649
+ }
15650
+ }),
15651
+ [softphone]
15652
+ );
15653
+ return { notices, dismiss };
15654
+ }
15307
15655
  function SoftphoneWidget(props) {
15308
15656
  const {
15309
15657
  contacts,
@@ -15328,7 +15676,9 @@ function SoftphoneWidget(props) {
15328
15676
  useCallAudio(sounds);
15329
15677
  const blocked = requireMic && mic.state !== "ready";
15330
15678
  const fatal = useLastError();
15679
+ const screenIsFree = !call && !blocked && !fatal && !isFollower;
15331
15680
  const { outcome, clear: clearOutcome } = useCallOutcome();
15681
+ const { notices: transferNotices, dismiss: dismissTransferNotice } = useTransferNotices();
15332
15682
  const retry = () => {
15333
15683
  void (async () => {
15334
15684
  try {
@@ -15352,8 +15702,9 @@ function SoftphoneWidget(props) {
15352
15702
  className: className ? `cv-softphone ${className}` : "cv-softphone",
15353
15703
  ...theme === "system" ? {} : { "data-cv-theme": theme },
15354
15704
  children: [
15705
+ screenIsFree ? /* @__PURE__ */ jsxRuntime.jsx(TransferNotices, { notices: transferNotices, dismiss: dismissTransferNotice }) : null,
15355
15706
  /* @__PURE__ */ jsxRuntime.jsx("div", { className: "cv-body", children: isFollower ? /* @__PURE__ */ jsxRuntime.jsx(LeaderGate, {}) : blocked ? /* @__PURE__ */ jsxRuntime.jsx(MicPrompt, { state: mic.state, onRequest: () => void mic.request() }) : call ? /* @__PURE__ */ jsxRuntime.jsx(CallScreen, { call, ...incomingCallAction ? { incomingCallAction } : {} }) : fatal ? /* @__PURE__ */ jsxRuntime.jsx(ErrorGate, { error: fatal, onRetry: retry }) : tab === "contacts" && contactsSource ? /* @__PURE__ */ jsxRuntime.jsx(ContactsTab, { source: contactsSource, ...contactAction ? { contactAction } : {} }) : tab === "history" && historySource ? /* @__PURE__ */ jsxRuntime.jsx(HistoryTab, { source: historySource }) : tab === "settings" ? /* @__PURE__ */ jsxRuntime.jsx(SettingsTab, {}) : /* @__PURE__ */ jsxRuntime.jsx(Dialpad, { outcome, onIntent: clearOutcome }) }),
15356
- call || blocked || fatal || isFollower ? null : /* @__PURE__ */ jsxRuntime.jsx("div", { className: "cv-tabs", role: "tablist", children: tabs.map(({ id, icon: Icon3, label }) => /* @__PURE__ */ jsxRuntime.jsxs(
15707
+ screenIsFree ? /* @__PURE__ */ jsxRuntime.jsx("div", { className: "cv-tabs", role: "tablist", children: tabs.map(({ id, icon: Icon3, label }) => /* @__PURE__ */ jsxRuntime.jsxs(
15357
15708
  "button",
15358
15709
  {
15359
15710
  role: "tab",
@@ -15366,7 +15717,7 @@ function SoftphoneWidget(props) {
15366
15717
  ]
15367
15718
  },
15368
15719
  id
15369
- )) })
15720
+ )) }) : null
15370
15721
  ]
15371
15722
  }
15372
15723
  );