@coventia/softphone 0.3.0 → 0.4.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.
package/dist/ui/index.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { isWebRtcSupported } from '../chunk-KAXEMJ7E.js';
2
2
  import { useSoftphone, useSoftphoneState, useCall, usePresence, useActiveCall, useIsLeader, useLastError, useDevices } from '../chunk-UHNLEBBO.js';
3
3
  import '../chunk-7R6LOOAI.js';
4
+ import { isOutboundNumberRefused } from '../chunk-5E5OEXS2.js';
4
5
  import { forwardRef, createElement, useState, useRef, useLayoutEffect, useEffect, useCallback, useMemo, useSyncExternalStore, useId } from 'react';
5
6
  import { jsxs, jsx, Fragment } from 'react/jsx-runtime';
6
7
 
@@ -367,6 +368,36 @@ var PACKED = "AC247 AD376 AE971 AF93 AG1 AI1 AL355 AM374 AO244 AR54 AS1 AT43 AU6
367
368
  var DIAL_CODES = new Map(
368
369
  PACKED.split(" ").map((token) => [token.slice(0, 2), Number(token.slice(2))])
369
370
  );
371
+ var REGIONS_BY_CODE = (() => {
372
+ const map = /* @__PURE__ */ new Map();
373
+ for (const [region, code] of DIAL_CODES) map.set(code, [...map.get(code) ?? [], region]);
374
+ return map;
375
+ })();
376
+ var MAIN_REGION = {
377
+ 1: "US",
378
+ 7: "RU",
379
+ 39: "IT",
380
+ 44: "GB",
381
+ 47: "NO",
382
+ 61: "AU",
383
+ 212: "MA",
384
+ 262: "RE",
385
+ 290: "SH",
386
+ 358: "FI",
387
+ 590: "GP",
388
+ 599: "CW"
389
+ };
390
+ function regionOfInternational(typed) {
391
+ const text = String(typed ?? "").trim();
392
+ if (!text.startsWith("+")) return null;
393
+ const digits = text.slice(1).replace(/\D/g, "");
394
+ for (let length = 1; length <= Math.min(3, digits.length); length++) {
395
+ const code = Number(digits.slice(0, length));
396
+ const regions = REGIONS_BY_CODE.get(code);
397
+ if (regions) return MAIN_REGION[code] ?? regions[0] ?? null;
398
+ }
399
+ return null;
400
+ }
370
401
  var DEFAULT_COUNTRY = "ES";
371
402
  var REGION_NAMES = (() => {
372
403
  try {
@@ -418,12 +449,16 @@ function CountryMark({ code }) {
418
449
  }
419
450
  function CountryPicker({
420
451
  value,
421
- onChange
452
+ onChange,
453
+ fromNumber = false
422
454
  }) {
423
455
  const [open, setOpen] = useState(false);
424
456
  const [query, setQuery] = useState("");
425
457
  const rootRef = useRef(null);
426
458
  const inputRef = useRef(null);
459
+ useEffect(() => {
460
+ if (fromNumber) setOpen(false);
461
+ }, [fromNumber]);
427
462
  const results = useMemo(() => {
428
463
  const q = query.trim().toLowerCase().replace(/^\+/, "");
429
464
  if (!q) return COUNTRIES;
@@ -452,6 +487,12 @@ function CountryPicker({
452
487
  else setQuery("");
453
488
  }, [open]);
454
489
  const dial = DIAL_CODES.get(value);
490
+ if (fromNumber) {
491
+ return /* @__PURE__ */ jsx("div", { className: "cv-cp", ref: rootRef, children: /* @__PURE__ */ jsxs("span", { className: "cv-cp-chip", "data-from-number": true, title: "El prefijo viene del n\xFAmero marcado", part: "country-chip", children: [
492
+ /* @__PURE__ */ jsx(CountryMark, { code: value || "+" }),
493
+ /* @__PURE__ */ jsx("span", { className: "cv-sr-only", children: dial ? `Prefijo del n\xFAmero marcado: ${countryName(value)} (+${dial})` : "Prefijo del n\xFAmero marcado" })
494
+ ] }) });
495
+ }
455
496
  return /* @__PURE__ */ jsxs("div", { className: "cv-cp", ref: rootRef, children: [
456
497
  /* @__PURE__ */ jsxs(
457
498
  "button",
@@ -1018,18 +1059,378 @@ function playRingback() {
1018
1059
  }
1019
1060
  };
1020
1061
  }
1062
+ var stores = /* @__PURE__ */ new WeakMap();
1063
+ function outboundNumberStore(softphone) {
1064
+ const source = softphone.sources?.outboundNumbers;
1065
+ if (!source) return null;
1066
+ let store = stores.get(softphone);
1067
+ if (!store) {
1068
+ store = createOutboundNumberStore(source);
1069
+ stores.set(softphone, store);
1070
+ }
1071
+ return store;
1072
+ }
1073
+ function createOutboundNumberStore(source) {
1074
+ let settings = null;
1075
+ let inFlight = null;
1076
+ const listeners = /* @__PURE__ */ new Set();
1077
+ const apply = (next) => {
1078
+ settings = next;
1079
+ for (const l of listeners) l();
1080
+ };
1081
+ return {
1082
+ getSettings: () => settings,
1083
+ refresh() {
1084
+ inFlight ??= (async () => {
1085
+ if (!settings && source.cached) {
1086
+ const early = await source.cached().catch(() => null);
1087
+ if (early && !settings) apply(early);
1088
+ }
1089
+ try {
1090
+ apply(await source.get());
1091
+ } catch (error) {
1092
+ console.warn("[softphone] outbound numbers could not be loaded; keeping the previous ones", error);
1093
+ } finally {
1094
+ inFlight = null;
1095
+ }
1096
+ })();
1097
+ return inFlight;
1098
+ },
1099
+ async setMine(id) {
1100
+ apply(await source.setMine(id));
1101
+ },
1102
+ subscribe(listener) {
1103
+ listeners.add(listener);
1104
+ return () => listeners.delete(listener);
1105
+ }
1106
+ };
1107
+ }
1108
+ var noSubscribe = () => () => {
1109
+ };
1110
+ var noSettings = () => null;
1111
+ function useOutboundNumbers(store) {
1112
+ const settings = useSyncExternalStore(store?.subscribe ?? noSubscribe, store?.getSettings ?? noSettings);
1113
+ const [failed, setFailed] = useState(false);
1114
+ const load = useCallback(() => {
1115
+ if (!store) return () => {
1116
+ };
1117
+ let live = true;
1118
+ setFailed(false);
1119
+ void store.refresh().then(() => {
1120
+ if (live) setFailed(store.getSettings() === null);
1121
+ });
1122
+ return () => {
1123
+ live = false;
1124
+ };
1125
+ }, [store]);
1126
+ useEffect(load, [load]);
1127
+ return { settings, failed, retry: load };
1128
+ }
1129
+ function useKnownOutboundNumbers(store) {
1130
+ return useSyncExternalStore(store?.subscribe ?? noSubscribe, store?.getSettings ?? noSettings);
1131
+ }
1132
+ function useCountdown(until) {
1133
+ const [, setTick] = useState(0);
1134
+ useEffect(() => {
1135
+ if (!until) return void 0;
1136
+ const timer = setInterval(() => {
1137
+ setTick((n) => n + 1);
1138
+ if (secondsLeft(until) === 0) clearInterval(timer);
1139
+ }, 1e3);
1140
+ return () => clearInterval(timer);
1141
+ }, [until]);
1142
+ return secondsLeft(until);
1143
+ }
1144
+ function secondsLeft(until) {
1145
+ return until ? Math.max(0, Math.ceil((until - Date.now()) / 1e3)) : 0;
1146
+ }
1147
+ function numberName(n, format = formatIntl) {
1148
+ if (!n) return "N\xFAmero de la organizaci\xF3n";
1149
+ return n.label || format(`+${n.number}`);
1150
+ }
1151
+ function effectiveNumber(settings) {
1152
+ return settings?.numbers.find((n) => n.id === settings.effective);
1153
+ }
1154
+
1155
+ // src/ui/outbound/outbound-change.ts
1156
+ var KIND_TAG = {
1157
+ mobile: { tag: "M\xF3vil", hint: "No usar para atenci\xF3n al cliente ni para llamadas comerciales no solicitadas." },
1158
+ commercial: { tag: "Comercial (400)", hint: "Solo para llamadas comerciales. No recibe llamadas." }
1159
+ };
1160
+ function noticeText(situation, seconds, current) {
1161
+ switch (situation) {
1162
+ case "platform":
1163
+ return `El cambio de n\xFAmero de salida no est\xE1 disponible temporalmente. Vuelve a intentarlo en ${seconds} s. Hasta entonces, tus llamadas seguir\xE1n saliendo desde ${current}.`;
1164
+ case "user":
1165
+ return `Acabas de cambiar el n\xFAmero de salida. Podr\xE1s cambiarlo de nuevo en ${seconds} s. Hasta entonces, tus llamadas saldr\xE1n desde ${current}.`;
1166
+ case "wait":
1167
+ return `Podr\xE1s cambiar el n\xFAmero de salida en ${seconds} s.`;
1168
+ case "pending":
1169
+ return seconds > 0 ? `Tu n\xFAmero de salida todav\xEDa no se ha aplicado. Podr\xE1s volver a elegirlo en ${seconds} s.` : "Tu n\xFAmero de salida todav\xEDa no se ha aplicado. Vuelve a elegirlo para aplicarlo.";
1170
+ }
1171
+ }
1172
+ function useOutboundNumberChange(store, settings, current) {
1173
+ const [busy, setBusy] = useState(false);
1174
+ const [notice, setNotice] = useState(null);
1175
+ const [waitUntil, setWaitUntil] = useState(null);
1176
+ useEffect(() => {
1177
+ const s = settings?.changeAvailableInSeconds ?? 0;
1178
+ setWaitUntil(s > 0 ? Date.now() + s * 1e3 : null);
1179
+ }, [settings]);
1180
+ const refusedUntil = notice?.kind === "refused" ? notice.until : null;
1181
+ const blockedFor = useCountdown(refusedUntil ?? waitUntil);
1182
+ useEffect(() => {
1183
+ if (notice?.kind === "refused" && blockedFor === 0) setNotice(null);
1184
+ }, [notice, blockedFor]);
1185
+ const locked = busy || blockedFor > 0;
1186
+ const choose = async (id) => {
1187
+ if (!store || !settings || locked) return false;
1188
+ if (id === settings.mine) return true;
1189
+ setBusy(true);
1190
+ setNotice(null);
1191
+ try {
1192
+ await store.setMine(id);
1193
+ return true;
1194
+ } catch (error) {
1195
+ setNotice(
1196
+ isOutboundNumberRefused(error) ? { kind: "refused", reason: error.refusal.reason, until: Date.now() + error.refusal.retryAfterSeconds * 1e3 } : { kind: "error", text: errorText(error) }
1197
+ );
1198
+ return false;
1199
+ } finally {
1200
+ setBusy(false);
1201
+ }
1202
+ };
1203
+ return {
1204
+ busy,
1205
+ locked,
1206
+ choose,
1207
+ /** What to say about the choice right now, if anything. */
1208
+ message: settings ? currentMessage(notice, settings.state, blockedFor, current) : null,
1209
+ /** The message is an error (the change failed), not information (wait, pending). */
1210
+ isError: notice?.kind === "error"
1211
+ };
1212
+ }
1213
+ function currentMessage(notice, state, blockedFor, current) {
1214
+ if (notice?.kind === "refused") return blockedFor > 0 ? noticeText(notice.reason, blockedFor, current) : null;
1215
+ if (notice?.kind === "error") return notice.text;
1216
+ if (state === "pending" || state === "failed") return noticeText("pending", blockedFor, current);
1217
+ return blockedFor > 0 ? noticeText("wait", blockedFor, current) : null;
1218
+ }
1219
+ function errorText(error) {
1220
+ const message = error instanceof Error ? error.message : "";
1221
+ return message && !/HTTP \d+/.test(message) ? message : "No se ha podido cambiar el n\xFAmero de salida. Int\xE9ntalo de nuevo.";
1222
+ }
1223
+ var CALLOUT_MS = 6e3;
1224
+ function OutboundNumberPicker({ onBusyChange }) {
1225
+ const softphone = useSoftphone();
1226
+ const store = outboundNumberStore(softphone);
1227
+ const { settings } = useOutboundNumbers(store);
1228
+ const formatIntl2 = useIntlFormatter();
1229
+ const current = settings ? numberName(effectiveNumber(settings), formatIntl2) : "";
1230
+ const { busy, locked, choose: apply, message, isError } = useOutboundNumberChange(store, settings, current);
1231
+ const [open, setOpen] = useState(false);
1232
+ const [applying, setApplying] = useState(void 0);
1233
+ const [callout, setCallout] = useState(false);
1234
+ const rootRef = useRef(null);
1235
+ const chipRef = useRef(null);
1236
+ const keys = useListKeyboard(open);
1237
+ useEffect(() => onBusyChange?.(busy), [busy, onBusyChange]);
1238
+ useEffect(() => {
1239
+ if (!message) setCallout(false);
1240
+ }, [message]);
1241
+ useEffect(() => {
1242
+ if (!callout) return void 0;
1243
+ const timer = setTimeout(() => setCallout(false), CALLOUT_MS);
1244
+ return () => clearTimeout(timer);
1245
+ }, [callout]);
1246
+ const floating = open || callout;
1247
+ useEffect(() => {
1248
+ if (!floating) return;
1249
+ const close = () => {
1250
+ setOpen(false);
1251
+ setCallout(false);
1252
+ };
1253
+ const root = rootRef.current?.getRootNode() ?? document;
1254
+ const onDown = (e) => {
1255
+ if (!rootRef.current?.contains(e.target)) close();
1256
+ };
1257
+ const onKey = (e) => {
1258
+ if (e.key !== "Escape") return;
1259
+ if (rootRef.current?.contains(e.target)) chipRef.current?.focus();
1260
+ close();
1261
+ };
1262
+ root.addEventListener("pointerdown", onDown);
1263
+ document.addEventListener("keydown", onKey);
1264
+ return () => {
1265
+ root.removeEventListener("pointerdown", onDown);
1266
+ document.removeEventListener("keydown", onKey);
1267
+ };
1268
+ }, [floating]);
1269
+ if (!store || !settings || settings.numbers.length < 2) return null;
1270
+ const orgDefault = settings.numbers.find((n) => n.id === settings.orgDefault);
1271
+ const applyingNumber = applying === null ? orgDefault : settings.numbers.find((n) => n.id === applying);
1272
+ const shown = applying === void 0 ? current : numberName(applyingNumber, formatIntl2);
1273
+ const choose = async (id) => {
1274
+ if (locked) return;
1275
+ setOpen(false);
1276
+ chipRef.current?.focus();
1277
+ if (id === settings.mine) return;
1278
+ setCallout(false);
1279
+ setApplying(id);
1280
+ const applied = await apply(id);
1281
+ setApplying(void 0);
1282
+ if (!applied) setCallout(true);
1283
+ };
1284
+ return /* @__PURE__ */ jsxs("div", { className: "cv-on", ref: rootRef, children: [
1285
+ /* @__PURE__ */ jsxs(
1286
+ "button",
1287
+ {
1288
+ ref: chipRef,
1289
+ type: "button",
1290
+ className: "cv-on-chip",
1291
+ onClick: () => {
1292
+ setCallout(false);
1293
+ setOpen((o) => !o);
1294
+ },
1295
+ "aria-haspopup": "listbox",
1296
+ "aria-expanded": open,
1297
+ "aria-label": `Llamar desde: ${shown}${busy ? " (aplicando)" : ""}. Cambiar n\xFAmero de salida`,
1298
+ "aria-busy": busy,
1299
+ part: "outbound-number-chip",
1300
+ children: [
1301
+ /* @__PURE__ */ jsx("span", { className: "cv-on-label", children: "Llamar desde:" }),
1302
+ busy ? /* @__PURE__ */ jsx(LoaderCircle, { size: 13, className: "cv-on-icon cv-on-spin", "aria-hidden": true }) : null,
1303
+ /* @__PURE__ */ jsx("span", { className: "cv-on-current", children: shown }),
1304
+ /* @__PURE__ */ jsx(ChevronDown, { size: 13, className: "cv-cp-caret", "data-open": open, "aria-hidden": true })
1305
+ ]
1306
+ }
1307
+ ),
1308
+ open ? /* @__PURE__ */ jsxs(
1309
+ "div",
1310
+ {
1311
+ className: "cv-cp-pop cv-on-pop",
1312
+ role: "dialog",
1313
+ "aria-label": "Llamar desde",
1314
+ part: "outbound-number-list",
1315
+ onBlur: (e) => {
1316
+ if (!e.currentTarget.contains(e.relatedTarget)) setOpen(false);
1317
+ },
1318
+ children: [
1319
+ /* @__PURE__ */ jsx("p", { className: "cv-on-title", children: "Llamar desde" }),
1320
+ /* @__PURE__ */ jsxs("div", { className: "cv-cp-list", role: "listbox", "aria-disabled": locked, ref: keys.listRef, onKeyDown: keys.onKeyDown, children: [
1321
+ /* @__PURE__ */ jsx(
1322
+ OptionRow,
1323
+ {
1324
+ selected: settings.mine === null,
1325
+ disabled: locked,
1326
+ title: "Por defecto de la organizaci\xF3n",
1327
+ sub: orgDefault ? numberName(orgDefault, formatIntl2) : "Sin n\xFAmero por defecto",
1328
+ onSelect: () => void choose(null)
1329
+ }
1330
+ ),
1331
+ settings.numbers.map((n) => /* @__PURE__ */ jsx(
1332
+ OptionRow,
1333
+ {
1334
+ selected: settings.mine === n.id,
1335
+ disabled: locked,
1336
+ title: numberName(n, formatIntl2),
1337
+ sub: n.label ? formatIntl2(`+${n.number}`) : null,
1338
+ tag: KIND_TAG[n.kind],
1339
+ onSelect: () => void choose(n.id)
1340
+ },
1341
+ n.id
1342
+ ))
1343
+ ] }),
1344
+ message ? /* @__PURE__ */ jsx("p", { className: "cv-on-notice", role: "status", "data-kind": isError ? "error" : "info", children: message }) : null
1345
+ ]
1346
+ }
1347
+ ) : null,
1348
+ callout && !open && message ? /* @__PURE__ */ jsx("p", { className: "cv-on-callout", role: "status", "data-kind": isError ? "error" : "info", part: "outbound-number-notice", children: message }) : null
1349
+ ] });
1350
+ }
1351
+ function OptionRow({
1352
+ selected,
1353
+ disabled,
1354
+ title,
1355
+ sub,
1356
+ tag,
1357
+ onSelect
1358
+ }) {
1359
+ return /* @__PURE__ */ jsxs(
1360
+ "button",
1361
+ {
1362
+ type: "button",
1363
+ role: "option",
1364
+ "aria-selected": selected,
1365
+ "aria-disabled": disabled,
1366
+ className: "cv-cp-item cv-on-item",
1367
+ tabIndex: -1,
1368
+ onClick: onSelect,
1369
+ children: [
1370
+ /* @__PURE__ */ jsxs("span", { className: "cv-on-text", children: [
1371
+ /* @__PURE__ */ jsxs("span", { className: "cv-on-line", children: [
1372
+ /* @__PURE__ */ jsx("span", { className: "cv-cp-name", children: title }),
1373
+ tag ? /* @__PURE__ */ jsx("span", { className: "cv-on-tag", children: tag.tag }) : null
1374
+ ] }),
1375
+ sub ? /* @__PURE__ */ jsx("span", { className: "cv-on-sub", children: sub }) : null,
1376
+ tag ? /* @__PURE__ */ jsx("span", { className: "cv-on-hint", children: tag.hint }) : null
1377
+ ] }),
1378
+ /* @__PURE__ */ jsx(Check, { size: 14, className: "cv-cp-check", "data-on": selected, "aria-hidden": true })
1379
+ ]
1380
+ }
1381
+ );
1382
+ }
1383
+ var optionsOf = (list) => list ? [...list.querySelectorAll('[role="option"]')] : [];
1384
+ var fold = (text) => text.normalize("NFD").replace(/\p{Diacritic}/gu, "").toLowerCase();
1385
+ var TYPEAHEAD_MS = 700;
1386
+ function useListKeyboard(open) {
1387
+ const listRef = useRef(null);
1388
+ const typed = useRef({ text: "", at: 0 });
1389
+ useEffect(() => {
1390
+ if (!open) return;
1391
+ const options = optionsOf(listRef.current);
1392
+ (options.find((o) => o.getAttribute("aria-selected") === "true") ?? options[0])?.focus();
1393
+ }, [open]);
1394
+ const onKeyDown = (e) => {
1395
+ const options = optionsOf(listRef.current);
1396
+ const at = options.indexOf(e.target);
1397
+ const move = {
1398
+ ArrowDown: Math.min(at + 1, options.length - 1),
1399
+ ArrowUp: Math.max(at - 1, 0),
1400
+ Home: 0,
1401
+ End: options.length - 1
1402
+ };
1403
+ let next = move[e.key];
1404
+ if (next === void 0 && e.key.length === 1 && !e.ctrlKey && !e.metaKey && !e.altKey) {
1405
+ const now = Date.now();
1406
+ const text = now - typed.current.at < TYPEAHEAD_MS ? typed.current.text + e.key : e.key;
1407
+ typed.current = { text, at: now };
1408
+ const wanted = fold(text);
1409
+ const match = options.findIndex((o) => fold(o.querySelector(".cv-cp-name")?.textContent ?? "").startsWith(wanted));
1410
+ next = match >= 0 ? match : void 0;
1411
+ }
1412
+ if (next === void 0) return;
1413
+ e.preventDefault();
1414
+ options[next]?.focus();
1415
+ };
1416
+ return { listRef, onKeyDown };
1417
+ }
1021
1418
  var NOT_DIALABLE = /* @__PURE__ */ new Set(["*", "#"]);
1419
+ var DISPLAY_SIZES = ["1.375rem", "1.25rem", "1.125rem", "1rem"];
1022
1420
  function Dialpad({ outcome = null, onIntent, keyTones = true } = {}) {
1023
1421
  const softphone = useSoftphone();
1024
1422
  const state = useSoftphoneState();
1025
1423
  const [value, setValue] = useState("");
1026
1424
  const [country, setCountry] = useState(DEFAULT_COUNTRY);
1425
+ const [changingNumber, setChangingNumber] = useState(false);
1027
1426
  const format = useNumberFormatter();
1028
1427
  const inputRef = useRef(null);
1029
1428
  const caretRef = useRef(null);
1030
1429
  const display = format(country, value);
1031
1430
  const target = toE164(country, value);
1032
- const canCall = state === "online" && target.length > 1;
1431
+ const canCall = state === "online" && target.length > 1 && !changingNumber;
1432
+ const typedInternational = value.trim().startsWith("+");
1433
+ const numberRegion = regionOfInternational(value);
1033
1434
  useLayoutEffect(() => {
1034
1435
  const caret = caretRef.current;
1035
1436
  if (caret === null || !inputRef.current) return;
@@ -1037,6 +1438,14 @@ function Dialpad({ outcome = null, onIntent, keyTones = true } = {}) {
1037
1438
  const pos = caretAfterDigits(display, caret);
1038
1439
  inputRef.current.setSelectionRange(pos, pos);
1039
1440
  }, [display]);
1441
+ useLayoutEffect(() => {
1442
+ const input = inputRef.current;
1443
+ if (!input) return;
1444
+ for (const size of DISPLAY_SIZES) {
1445
+ input.style.fontSize = size;
1446
+ if (input.scrollWidth <= input.clientWidth) break;
1447
+ }
1448
+ }, [display]);
1040
1449
  const append = (key) => {
1041
1450
  onIntent?.();
1042
1451
  setValue((v) => v + key);
@@ -1052,9 +1461,16 @@ function Dialpad({ outcome = null, onIntent, keyTones = true } = {}) {
1052
1461
  void softphone.call(target);
1053
1462
  };
1054
1463
  return /* @__PURE__ */ jsxs("div", { className: "cv-view", children: [
1055
- /* @__PURE__ */ jsx("div", { className: "cv-dial-prefix", children: /* @__PURE__ */ jsx(CountryPicker, { value: country, onChange: setCountry }) }),
1464
+ /* @__PURE__ */ jsx(OutboundNumberPicker, { onBusyChange: setChangingNumber }),
1056
1465
  /* @__PURE__ */ jsxs("div", { className: "cv-dial-head", children: [
1057
- /* @__PURE__ */ jsx("span", { className: "cv-dial-side", "aria-hidden": true }),
1466
+ /* @__PURE__ */ jsx(
1467
+ CountryPicker,
1468
+ {
1469
+ value: numberRegion ?? country,
1470
+ onChange: setCountry,
1471
+ fromNumber: typedInternational
1472
+ }
1473
+ ),
1058
1474
  /* @__PURE__ */ jsx(
1059
1475
  "input",
1060
1476
  {
@@ -1078,6 +1494,8 @@ function Dialpad({ outcome = null, onIntent, keyTones = true } = {}) {
1078
1494
  type: "button",
1079
1495
  className: "cv-dial-side",
1080
1496
  "data-hidden": value.length === 0,
1497
+ tabIndex: value.length === 0 ? -1 : void 0,
1498
+ "aria-hidden": value.length === 0 || void 0,
1081
1499
  onClick: backspace,
1082
1500
  "aria-label": "Borrar",
1083
1501
  children: /* @__PURE__ */ jsx(Delete, { size: 19 })
@@ -1502,6 +1920,9 @@ function CallScreen({
1502
1920
  }) {
1503
1921
  const view = useCall(call);
1504
1922
  const softphone = useSoftphone();
1923
+ const outbound = useOutboundNumbers(call.direction === "outbound" ? outboundNumberStore(softphone) : null).settings;
1924
+ const formatIntl2 = useIntlFormatter();
1925
+ const fromName = outbound && outbound.numbers.length > 1 ? numberName(effectiveNumber(outbound), formatIntl2) : null;
1505
1926
  const [panel, setPanel] = useState(null);
1506
1927
  const showTransfer = panel === "transfer";
1507
1928
  const showKeypad = panel === "keypad";
@@ -1712,6 +2133,13 @@ function CallScreen({
1712
2133
  ] }),
1713
2134
  /* @__PURE__ */ jsx("div", { className: "cv-call-name", children: name }),
1714
2135
  showNumber ? /* @__PURE__ */ jsx("div", { className: "cv-call-number", children: number }) : null,
2136
+ fromName ? /* @__PURE__ */ jsxs("div", { className: "cv-call-from", children: [
2137
+ /* @__PURE__ */ jsx(PhoneOutgoing, { size: 11, "aria-hidden": true }),
2138
+ /* @__PURE__ */ jsxs("span", { children: [
2139
+ "Desde ",
2140
+ fromName
2141
+ ] })
2142
+ ] }) : null,
1715
2143
  s === "active" ? /* @__PURE__ */ jsxs("div", { className: "cv-timer", "aria-label": "Duraci\xF3n de la llamada", children: [
1716
2144
  /* @__PURE__ */ jsx("b", { children: mm }),
1717
2145
  /* @__PURE__ */ jsx("i", { children: ":" }),
@@ -2127,12 +2555,23 @@ function HandlerChain({ handlers }) {
2127
2555
  ] }, `${h.extension}-${i}`);
2128
2556
  }) });
2129
2557
  }
2558
+ function outboundFrom(entry, known, formatIntl2) {
2559
+ if (entry.direction !== "outbound" || !entry.from) return null;
2560
+ const number = formatIntl2(`+${entry.from}`);
2561
+ const label = known?.numbers.find((n) => n.number === entry.from)?.label;
2562
+ if (!label) return number;
2563
+ return /* @__PURE__ */ jsxs(Fragment, { children: [
2564
+ label,
2565
+ /* @__PURE__ */ jsx("span", { className: "cv-detail-row-sub", children: number })
2566
+ ] });
2567
+ }
2130
2568
  function HistoryDetail({
2131
2569
  entry,
2132
2570
  onBack
2133
2571
  }) {
2134
2572
  const softphone = useSoftphone();
2135
2573
  const formatIntl2 = useIntlFormatter();
2574
+ const knownNumbers = useKnownOutboundNumbers(outboundNumberStore(softphone));
2136
2575
  const [detail, setDetail] = useState(null);
2137
2576
  const [loadingDetail, setLoadingDetail] = useState(false);
2138
2577
  useEffect(() => {
@@ -2148,6 +2587,7 @@ function HistoryDetail({
2148
2587
  }, [entry]);
2149
2588
  const formatted = formatIntl2(entry.number);
2150
2589
  const name = entry.name ?? formatted;
2590
+ const from = outboundFrom(entry, knownNumbers, formatIntl2);
2151
2591
  const handledBy = detail?.handledBy ?? entry.handledBy;
2152
2592
  const recordingUrls = detail?.recordingUrls ?? [];
2153
2593
  const loadingRecording = entry.recorded && loadingDetail && recordingUrls.length === 0;
@@ -2184,7 +2624,8 @@ function HistoryDetail({
2184
2624
  entry.durationSec > 0 ? /* @__PURE__ */ jsx(DetailRow, { icon: Timer, eyebrow: "Duraci\xF3n", text: formatDuration(entry.durationSec) }) : null,
2185
2625
  /* @__PURE__ */ jsx(DetailRow, { icon: Phone, eyebrow: "N\xFAmero", text: formatted }),
2186
2626
  handledBy.length > 0 ? /* @__PURE__ */ jsx(DetailRow, { icon: User, eyebrow: handlerLabel, text: /* @__PURE__ */ jsx(HandlerChain, { handlers: handledBy }) }) : null,
2187
- entry.direction === "inbound" && entry.did ? /* @__PURE__ */ jsx(DetailRow, { icon: Hash, eyebrow: "N\xFAmero marcado (DID)", text: formatIntl2(entry.did) }) : null
2627
+ entry.direction === "inbound" && entry.did ? /* @__PURE__ */ jsx(DetailRow, { icon: Hash, eyebrow: "N\xFAmero marcado (DID)", text: formatIntl2(entry.did) }) : null,
2628
+ from ? /* @__PURE__ */ jsx(DetailRow, { icon: PhoneOutgoing, eyebrow: "Llamada desde", text: from }) : null
2188
2629
  ] })
2189
2630
  ] }),
2190
2631
  /* @__PURE__ */ jsx("div", { className: "cv-detail-foot", children: /* @__PURE__ */ jsxs(
@@ -2379,14 +2820,14 @@ function HistoryTab({ source }) {
2379
2820
  }
2380
2821
 
2381
2822
  // src/ui/audio/ringtone-store.ts
2382
- var stores = /* @__PURE__ */ new WeakMap();
2823
+ var stores2 = /* @__PURE__ */ new WeakMap();
2383
2824
  function ringtoneStore(softphone) {
2384
2825
  const source = softphone.sources?.ringtones;
2385
2826
  if (!source) return null;
2386
- let store = stores.get(softphone);
2827
+ let store = stores2.get(softphone);
2387
2828
  if (!store) {
2388
2829
  store = createRingtoneStore(source);
2389
- stores.set(softphone, store);
2830
+ stores2.set(softphone, store);
2390
2831
  }
2391
2832
  return store;
2392
2833
  }
@@ -2512,17 +2953,19 @@ function ChoiceList({
2512
2953
  choices,
2513
2954
  value,
2514
2955
  onSelect,
2515
- onReselect
2956
+ onReselect,
2957
+ disabled = false
2516
2958
  }) {
2517
2959
  const name = useId();
2518
2960
  const pointer = useRef(false);
2519
- return /* @__PURE__ */ jsx("div", { className: "cv-choices", role: "radiogroup", "aria-label": label, children: choices.map((c) => {
2961
+ return /* @__PURE__ */ jsx("div", { className: "cv-choices", role: "radiogroup", "aria-label": label, "aria-disabled": disabled || void 0, children: choices.map((c) => {
2520
2962
  const checked = c.value === value;
2521
2963
  return /* @__PURE__ */ jsxs(
2522
2964
  "label",
2523
2965
  {
2524
2966
  className: "cv-choice",
2525
2967
  "data-checked": checked || void 0,
2968
+ "data-disabled": disabled || void 0,
2526
2969
  onPointerDown: () => {
2527
2970
  pointer.current = true;
2528
2971
  },
@@ -2538,19 +2981,23 @@ function ChoiceList({
2538
2981
  onChange: () => {
2539
2982
  const via = pointer.current ? "pointer" : "keyboard";
2540
2983
  pointer.current = false;
2541
- onSelect(c.value, via);
2984
+ if (!disabled) onSelect(c.value, via);
2542
2985
  },
2543
2986
  onClick: () => {
2544
2987
  if (c.value === value) {
2545
2988
  pointer.current = false;
2546
- onReselect?.(c.value);
2989
+ if (!disabled) onReselect?.(c.value);
2547
2990
  }
2548
2991
  }
2549
2992
  }
2550
2993
  ),
2551
2994
  /* @__PURE__ */ jsxs("span", { className: "cv-choice-text", children: [
2552
- /* @__PURE__ */ jsx("span", { className: "cv-choice-label", children: c.label }),
2553
- c.sub ? /* @__PURE__ */ jsx("span", { className: "cv-choice-sub", children: c.sub }) : null
2995
+ c.tag ? /* @__PURE__ */ jsxs("span", { className: "cv-choice-line", children: [
2996
+ /* @__PURE__ */ jsx("span", { className: "cv-choice-label", children: c.label }),
2997
+ /* @__PURE__ */ jsx("span", { className: "cv-choice-tag", children: c.tag })
2998
+ ] }) : /* @__PURE__ */ jsx("span", { className: "cv-choice-label", children: c.label }),
2999
+ c.sub ? /* @__PURE__ */ jsx("span", { className: "cv-choice-sub", children: c.sub }) : null,
3000
+ c.hint ? /* @__PURE__ */ jsx("span", { className: "cv-choice-hint", children: c.hint }) : null
2554
3001
  ] }),
2555
3002
  c.trailing,
2556
3003
  /* @__PURE__ */ jsx(Check, { size: 16, className: "cv-choice-check", "aria-hidden": true })
@@ -2712,11 +3159,11 @@ function previewRingtone(ringtone, options = {}) {
2712
3159
  }
2713
3160
 
2714
3161
  // src/ui/views/settings/ringtone-state.ts
2715
- var noSubscribe = () => () => {
3162
+ var noSubscribe2 = () => () => {
2716
3163
  };
2717
- var noSettings = () => null;
3164
+ var noSettings2 = () => null;
2718
3165
  function useRingtoneSettings(store) {
2719
- const settings = useSyncExternalStore(store?.subscribe ?? noSubscribe, store?.getSettings ?? noSettings);
3166
+ const settings = useSyncExternalStore(store?.subscribe ?? noSubscribe2, store?.getSettings ?? noSettings2);
2720
3167
  const [failed, setFailed] = useState(false);
2721
3168
  const load = useCallback(() => {
2722
3169
  if (!store) return () => {
@@ -2917,11 +3364,109 @@ function useSerialSave(store, handlers) {
2917
3364
  });
2918
3365
  return save;
2919
3366
  }
3367
+ var ORG_DEFAULT2 = "";
3368
+ var KEYBOARD_SETTLE_MS2 = 800;
3369
+ function OutboundNumberPage({
3370
+ store,
3371
+ settings,
3372
+ onBack
3373
+ }) {
3374
+ const formatIntl2 = useIntlFormatter();
3375
+ const current = numberName(effectiveNumber(settings), formatIntl2);
3376
+ const change = useOutboundNumberChange(store, settings, current);
3377
+ const [pending, setPending] = useState(void 0);
3378
+ const settle = useRef(null);
3379
+ const choose = useRef(change.choose);
3380
+ choose.current = change.choose;
3381
+ useEffect(
3382
+ () => () => {
3383
+ if (settle.current) clearTimeout(settle.current);
3384
+ },
3385
+ []
3386
+ );
3387
+ const notice = useRef(null);
3388
+ const apply = async (value) => {
3389
+ setPending(value);
3390
+ const applied = await choose.current(value || null);
3391
+ setPending(void 0);
3392
+ if (!applied) requestAnimationFrame(() => notice.current?.scrollIntoView?.({ block: "nearest" }));
3393
+ };
3394
+ const onSelect = (value, via) => {
3395
+ if (settle.current) clearTimeout(settle.current);
3396
+ settle.current = null;
3397
+ if (via === "pointer") {
3398
+ void apply(value);
3399
+ return;
3400
+ }
3401
+ setPending(value);
3402
+ settle.current = setTimeout(() => {
3403
+ settle.current = null;
3404
+ void apply(value);
3405
+ }, KEYBOARD_SETTLE_MS2);
3406
+ };
3407
+ return /* @__PURE__ */ jsxs(
3408
+ SettingsPage,
3409
+ {
3410
+ title: "N\xFAmero de salida",
3411
+ intro: "El n\xFAmero que ve la persona a la que llamas. Es el mismo en todos tus dispositivos y tambi\xE9n puedes cambiarlo desde el teclado.",
3412
+ onBack,
3413
+ children: [
3414
+ change.message ? /* @__PURE__ */ jsx("p", { className: "cv-settings-notice", role: "status", "data-kind": change.isError ? "error" : "info", ref: notice, children: change.message }) : null,
3415
+ /* @__PURE__ */ jsx(
3416
+ ChoiceList,
3417
+ {
3418
+ label: "N\xFAmero de salida",
3419
+ choices: choicesFor2(settings, formatIntl2, change.busy ? pending : void 0),
3420
+ value: pending ?? currentValue2(settings),
3421
+ onSelect,
3422
+ disabled: change.locked
3423
+ }
3424
+ )
3425
+ ]
3426
+ }
3427
+ );
3428
+ }
3429
+ function currentValue2(settings) {
3430
+ return settings.mine && settings.numbers.some((n) => n.id === settings.mine) ? settings.mine : ORG_DEFAULT2;
3431
+ }
3432
+ function choicesFor2(settings, formatIntl2, applying) {
3433
+ const orgDefault = settings.numbers.find((n) => n.id === settings.orgDefault);
3434
+ const spinner = (value) => applying === value ? /* @__PURE__ */ jsxs("span", { className: "cv-choice-applying", children: [
3435
+ /* @__PURE__ */ jsx(LoaderCircle, { size: 15, className: "cv-choice-applying-icon", "aria-hidden": true }),
3436
+ /* @__PURE__ */ jsx("span", { className: "cv-sr-only", children: "Aplicando" })
3437
+ ] }) : void 0;
3438
+ return [
3439
+ {
3440
+ value: ORG_DEFAULT2,
3441
+ label: "Por defecto de la organizaci\xF3n",
3442
+ sub: orgDefault ? numberName(orgDefault, formatIntl2) : "Sin n\xFAmero por defecto",
3443
+ trailing: spinner(ORG_DEFAULT2)
3444
+ },
3445
+ ...settings.numbers.map((n) => {
3446
+ const kind = KIND_TAG[n.kind];
3447
+ return {
3448
+ value: n.id,
3449
+ label: numberName(n, formatIntl2),
3450
+ ...n.label ? { sub: formatIntl2(`+${n.number}`) } : {},
3451
+ ...kind ? { tag: kind.tag, hint: kind.hint } : {},
3452
+ trailing: spinner(n.id)
3453
+ };
3454
+ })
3455
+ ];
3456
+ }
3457
+ function outboundSummary(settings, formatIntl2) {
3458
+ const effective = effectiveNumber(settings);
3459
+ const name = numberName(effective, formatIntl2);
3460
+ return settings.mine || !effective ? name : `${name} \xB7 organizaci\xF3n`;
3461
+ }
2920
3462
  function SettingsTab() {
2921
3463
  const softphone = useSoftphone();
2922
3464
  const devices = useDevices();
2923
3465
  const store = ringtoneStore(softphone);
2924
3466
  const ringtone = useRingtoneSettings(store);
3467
+ const outStore = outboundNumberStore(softphone);
3468
+ const outbound = useOutboundNumbers(outStore).settings;
3469
+ const formatIntl2 = useIntlFormatter();
2925
3470
  const [page, setPage] = useState(null);
2926
3471
  const list = useRef(null);
2927
3472
  const rows = useRef({});
@@ -2956,6 +3501,16 @@ function SettingsTab() {
2956
3501
  rowRef: rowRef("tone")
2957
3502
  }
2958
3503
  ) }) : null,
3504
+ outStore && outbound && outbound.numbers.length > 1 ? /* @__PURE__ */ jsx(SettingsSection, { title: "Llamadas salientes", children: /* @__PURE__ */ jsx(
3505
+ SettingsNavRow,
3506
+ {
3507
+ icon: PhoneOutgoing,
3508
+ title: "N\xFAmero de salida",
3509
+ value: outboundSummary(outbound, formatIntl2),
3510
+ onOpen: open("outbound"),
3511
+ rowRef: rowRef("outbound")
3512
+ }
3513
+ ) }) : null,
2959
3514
  /* @__PURE__ */ jsxs(SettingsSection, { title: "Dispositivos", children: [
2960
3515
  /* @__PURE__ */ jsx(
2961
3516
  SettingsNavRow,
@@ -3009,7 +3564,7 @@ function SettingsTab() {
3009
3564
  onRetry: ringtone.retry,
3010
3565
  onBack: back
3011
3566
  }
3012
- ) : page !== null && page !== "tone" ? /* @__PURE__ */ jsx(DevicePage, { kind: page, onBack: back }) : null
3567
+ ) : page === "outbound" && outStore && outbound ? /* @__PURE__ */ jsx(OutboundNumberPage, { store: outStore, settings: outbound, onBack: back }) : page !== null && page !== "tone" && page !== "outbound" ? /* @__PURE__ */ jsx(DevicePage, { kind: page, onBack: back }) : null
3013
3568
  ] });
3014
3569
  }
3015
3570
  function useMicPermission() {