npm.io
0.7.1 • Published 23h ago

@bcvoz/webphone

Licence
UNLICENSED
Version
0.7.1
Deps
0
Vulns
0
Weekly
0

@bcvoz/webphone

Webphone BCVOZ embutível em qualquer página web: o softphone aparece como uma janela flutuante arrastável, com as mesmas funcionalidades da extensão de navegador.

<script>
// Resolve a versão publicada e carrega o bundle dela.
// Duas etapas de propósito: a consulta tem 5 min de cache, o bundle é
// immutable. Assim as correções chegam em minutos, sem revalidar 30 kB a cada
// visita — e sem os 7 dias de cache que a URL sem versão carrega.
// cache:'no-store' na CONSULTA (~1 kB): sem ele, a resposta fica até 5 min
// no navegador e uma publicação recém-saída não aparece — foi o que exigiu
// Ctrl+Shift+R nos testes. O bundle continua vindo de cache immutable, então
// o custo é uma requisição pequena por carregamento, não 30 kB.
fetch('https://data.jsdelivr.com/v1/packages/npm/@bcvoz/webphone/resolved',
      { cache: 'no-store' })
  .then((r) => r.json())
  .then(({ version }) => {
    const s = document.createElement('script')
    s.src = `https://cdn.jsdelivr.net/npm/@bcvoz/webphone@${version}/dist/bcvoz.umd.js`
    s.onload = () => BCVoz.init({ session: SESSAO_DO_LOGIN })
    s.onerror = () => console.error('BCVoz: falha ao carregar do CDN')
    document.head.appendChild(s)
  })
</script>

Cole inline, não como arquivo externo — um arquivo externo teria o mesmo problema de cache que este trecho existe para evitar. Detalhes em Manter o cliente sempre atualizado.

Para travar numa versão (integração de terceiros, ou build com SRI):

<script src="https://cdn.jsdelivr.net/npm/@bcvoz/webphone@0.7.1/dist/bcvoz.umd.js"></script>
<script>BCVoz.init({ token: TOKEN_DO_USUARIO })</script>
// ou via npm
import BCVoz from '@bcvoz/webphone'

BCVoz.init({ token })
BCVoz.on('call:incoming', ({ number }) => console.log('ligação de', number))
await BCVoz.call('11987654321')

Vindo do Bravophone

O produto passou a se chamar BCVoz. A API é a mesma; mudam os nomes:

Antes Agora
@bravophone/webphone @bcvoz/webphone
dist/bravophone.umd.js / .mjs dist/bcvoz.umd.js / .mjs
window.Bravophone window.BCVoz
tipos Bravophone* tipos BCVoz*

window.Bravophone continua funcionando como alias (com um aviso no console), e os tipos Bravophone* seguem exportados como @deprecated. A sessão salva, os cabeçalhos X-Bravo-Device-* e os domínios não mudaram — ninguém é deslogado na troca.


A decisão de arquitetura

O ponto de partida é uma restrição concreta: popup.js tem 932 KB de build Vue minificado e o código-fonte não está disponível. Recompilar não é uma opção, então o projeto foi desenhado para reaproveitar o bundle exatamente como está.

O levantamento do uso de chrome.* no bundle mostrou que isso é viável — a superfície é pequena e concentrada:

API Usos Tratamento
chrome.storage.sync 58 shim → localStorage
chrome.storage.local 10 shim → localStorage
chrome.storage.onChanged 4 shim → emissor próprio
chrome.runtime.onMessage 3 shim → barramento local
chrome.tabs.create 3 shim → window.open
chrome.windows.* 6 shim → delega ao widget via bridge

São três superfícies reais, todas sem estado remoto. Um shim de ~230 linhas cobre todas — é o host/shim/chrome-shim.js.

Dois artefatos, não um
┌─ SITE DO CLIENTE (qualquer origem) ────────────────────────┐
│                                                             │
│   <script src="cdn.../@bcvoz/webphone">                │
│            │                                                │
│            ▼                                                │
│   ┌─ SDK (11 KB) ──────────────────────┐                    │
│   │  Shadow DOM · janela arrastável    │                    │
│   │  API pública · ponte postMessage   │                    │
│   │                                    │                    │
│   │   ┌─ <iframe srcdoc> ───────────┐  │                    │
│   │   │  origem: a do próprio site  │  │                    │
│   │   │  arquivos: CDN (jsDelivr)   │  │                    │
│   │   │                             │  │                    │
│   │   │  chrome-shim.js             │  │                    │
│   │   │  libwebphone.js   (604 KB)  │  │                    │
│   │   │  popup.js         (932 KB)  │  │  ← bundle intacto  │
│   │   │  guest-bridge.js            │  │                    │
│   │   └─────────────────────────────┘  │                    │
│   └────────────────────────────────────┘                    │
└─────────────────────────────────────────────────────────────┘

O SDK no npm/CDN é leve (11 KB / 4,6 KB gzip). Todo o peso do webphone fica no host e carrega sob demanda, quando o usuário abre a janela.

Por que iframe, e não montar o Vue direto na página

Testei mentalmente as duas rotas; o iframe ganha em quatro frentes de uma vez:

  1. CSS. O bundle traz Tailwind + dark-theme.css globais. Injetado na página do cliente, ele quebraria o site do cliente — e o CSS do cliente quebraria o webphone.
  2. CORS, e este é o argumento decisivo. Dentro do iframe, todo request para pabx.teambravotech.com e devices.wavoip.com sai com Origin: https://webphone.bravophone.comuma origem só, fixa. Sem iframe, cada cliente novo exigiria liberar mais uma origem no CORS de três backends. Com iframe, a lista de origens do backend nunca cresce.
  3. Microfone. allow="microphone" no iframe é um contrato explícito e auditável.
  4. Atualização. Corrigiu algo no webphone? Republique o host. Todos os clientes recebem sem trocar a versão do pacote npm.

O que muda em relação à extensão

Login: o ponto que exige decisão de produto

Este é o único item que não tem solução puramente técnica, e vale ler antes de começar.

Desde o Chrome 115, o storage partitioning é padrão: o localStorage de um iframe cross-origin é particionado pelo site que o contém. Na prática, um usuário logado no webphone em clienteA.com não estará logado em clienteB.com — mesmo sendo o mesmo iframe, o mesmo usuário e a mesma origem. A extensão nunca teve esse problema porque tinha um storage único.

Três caminhos, em ordem de recomendação:

  1. Token do integrador (recomendado). O backend do cliente emite um token de sessão e passa em BCVoz.init({ token }). O SDK entrega ao iframe pela ponte e o guest-bridge grava onde o bundle já procura (vxToken). Sem tela de login, sem depender de cookie de terceiros, e é o modelo que Intercom/Twilio usam. Combina bem com o fato de que o vxToken é eterno — só logout explícito o encerra.
  2. Login em popup window. window.open para a origem do webphone (contexto first-party, sem partição), token volta por postMessage. Bom se não houver backend do lado do cliente.
  3. Storage Access API. Exige gesto do usuário e o suporte varia entre navegadores. Serve como fallback, não como plano principal.

O SDK já implementa o caminho 1 de ponta a ponta.

Funcionalidades que não portam

Os ~25 content-script-*.js (Pipedrive, HubSpot, Kommo, Salesforce…) injetam click-to-call em CRMs de terceiros. Isso é território exclusivo de extensão — uma biblioteca só roda onde foi incluída.

A substituição é a inversão do controle: em vez de o BCVoz entrar no CRM, o CRM chama o BCVoz.

document.querySelectorAll('[data-phone]').forEach((el) => {
  el.onclick = () => BCVoz.call(el.dataset.phone, { source: 'crm', id: el.dataset.id })
})

Também ficam de fora contextMenus (menu de contexto do navegador), devtools.js e a leitura de clipboard sem gesto do usuário.

O que se mantém idêntico

Registro SIP, áudio WebRTC, supressão de ruído, seleção de rota, histórico, contatos, transferência, DTMF, e a normalização de número — inclusive a regra de nunca inserir o 9º dígito: dialpad.call() continua sendo o funil único de ligações, então toda essa lógica é exatamente a mesma da extensão.


Estrutura

Bravophone-SDK/
├── src/                    ← vira o pacote npm (11 KB)
│   ├── index.js              API pública + registro de eventos
│   ├── widget.js             Shadow DOM, iframe, launcher
│   ├── draggable.js          arraste/resize com Pointer Events + persistência
│   ├── bridge.js             RPC postMessage (lado host)
│   └── styles.js             CSS isolado do widget
│
├── host/                   ← vira webphone.bravophone.com (RAIZ do domínio)
│   ├── index.html            gerado pelo sync (ordem de scripts importa)
│   ├── shim/chrome-shim.js   emula chrome.* para o bundle
│   ├── shim/guest-bridge.js  RPC (lado iframe) + eventos + arraste interno
│   ├── allowed-origins.json  origens autorizadas a embutir
│   ├── popup.js  js/  css/   ┐ copiados da extensão pelo sync,
│   ├── fonts/  images/       ┘ na RAIZ — não versionados (ver abaixo)
│   ├── mock.html             ┐ só desenvolvimento:
│   └── mock-webphone.js      ┘ webphone falso, sem SIP nem backend
│
├── scripts/
│   ├── sync-from-extension.mjs   copia os assets da extensão
│   ├── dev-server.mjs            duas origens locais (5173 / 5174)
│   └── smoke-shim.mjs            testes do chrome-shim
├── types/index.d.ts
└── examples/
    ├── test.html             painel de teste completo (usa o build UMD)
    └── basic.html            exemplo mínimo de integração

A extensão é a fonte da verdade. Nada copiado é editado à mão. Quando a extensão for atualizada:

npm run sync          # host servido de um domínio próprio (public path "/")
npm run prepare:host  # host servido do CDN — é o que vai no pacote publicado

O prepare:host reescreve o public_path do bundle para cdn.jsdelivr.net/npm/@bcvoz/webphone@<versão>/host/. Rode-o depois de cada bump de versão e antes de publicar: se a URL apontar para outra versão, o pacote busca assets que podem não existir. O npm test recusa esse descompasso.

O script recusa rodar se um asset obrigatório sumir, em vez de gerar um host quebrado silenciosamente.

Por que os assets ficam na raiz de host/, e não num vendor/

O bundle foi buildado com __webpack_public_path__ = "/". Duas fontes são resolvidas por esse caminho absoluto:

n.p + "fonts/Audiowide-Regular.ttf"   // Audiowide — a fonte da marca
n.p + "fonts/Seguiemj.ttf"            // SegoeUIEmoji — os emojis

Sob um subdiretório, esses dois pedidos dão 404 e o navegador cai no fallback silenciosamente — sem erro visível, só a tipografia errada, e justamente depois do login, que é onde a Audiowide aparece. Replicar o layout de URL da extensão faz tudo resolver sem tocar no bundle: npm run sync termina verificando que as duas fontes aterrissaram em /fonts/, e falha alto se não.

Consequência de deploy: o host precisa ficar na raiz de um domínio ou subdomínio. Para servir sob um subpath, use npm run sync -- --public-path=/embed/ — troca só essa constante no bundle, de forma determinística e refeita a cada sync (e aborta se não encontrar exatamente uma ocorrência, em vez de adivinhar).


Desenvolvimento

Pré-requisito: a extensão ao lado

Este repositório não versiona o webphone — só o SDK e a camada que faz o bundle da extensão rodar fora dela. O popup.js (932 KB), o libwebphone.js, as fontes e os _locales são copiados da extensão pelo npm run sync e ficam fora do git.

Clone os dois como irmãos:

algum-diretorio/
├── Bravophone/       ← a extensão (fonte da verdade do webphone)
└── bravophone-sdk/   ← este repositório
git clone https://github.com/teambravotech/bravophone-sdk.git
cd bravophone-sdk
npm install
npm run sync      # copia os assets da extensão irmã
npm run build     # gera dist/ (ESM + UMD + sourcemaps)
npm start         # sobe as duas origens de teste

Se a extensão estiver em outro lugar, passe o caminho: npm run sync -- /caminho/para/Bravophone.

Sem o sync, o host não tem o que servir — npm start sobe, mas o webphone real não carrega (o mock em ?host=mock continua funcionando).

Scripts
Script O que faz
npm run sync Copia os assets da extensão, gera host/index.html e shim/messages.js, e roda a auditoria de tema
npm run build Gera dist/ — o que vai para o npm
npm start Sobe as duas origens locais (5173 site, 5174 host)
npm test 100 asserções: shim, geometria da janela e aba de abertura
npm run audit:theme Procura texto invisível no tema escuro
npm run purge Limpa o cache do CDN nas URLs sem versão fixa (roda sozinho após o publish)

Abra http://localhost:5173/.

O npm start sobe duas portas de propósito — origens diferentes fazem o teste exercitar o postMessage cross-origin de verdade, incluindo a validação de origem:

Porta Papel Serve
5173 site do cliente examples/test.html, carrega dist/bcvoz.umd.js por <script>, como no CDN
5174 host do webphone host/mock.html, com os headers frame-ancestors e Permissions-Policy de produção
Testar sem SIP nem backend

O host/mock.html carrega o chrome-shim.js e o guest-bridge.js reais e troca só o bundle por host/mock-webphone.js, que expõe os mesmos dois handles que o guest-bridge procura (window.dialpad e window.libwebphone). Ou seja: o caminho testado é o de produção, sem depender de registro SIP.

Dá para verificar ponta a ponta o arraste e o resize, a persistência da posição, os comandos (call/hangup/mute/transfer…), os eventos de volta, uma chamada entrante abrindo a janela sozinha, e o init({ token }) chegando ao storage via shim — o painel do mock mostra o vxToken gravado.

Para testar contra o webphone real, rode npm run sync e aponte o hostUrl do examples/test.html para http://localhost:5174/index.html em vez de mock.html.

npm test          # 18 asserções sobre o chrome-shim, sem browser

Deploy

1. Host — webphone.bravophone.com (raiz)

Estático (S3+CloudFront, Vercel, nginx). Três headers importam:

Content-Security-Policy: frame-ancestors 'self' https://clienteA.com https://clienteB.com;
Permissions-Policy: microphone=(self)
Cross-Origin-Opener-Policy: same-origin-allow-popups

frame-ancestors é o que impede qualquer site de embutir o webphone — deve ser gerado a partir de allowed-origins.json. É a mesma disciplina de autorização por origem que a extensão já adota; não troque por *.

Cache: popup.js, js/*, fonts/* com max-age=31536000 (invalide o CDN a cada sync, ou versione por query string); index.html sempre com no-cache.

2. Backends — CORS

Liberar uma única origem em api.bravophone.com, pabx.teambravotech.com e devices.wavoip.com:

Access-Control-Allow-Origin: https://webphone.bravophone.com
Access-Control-Allow-Credentials: true

Como o iframe tem origem fixa, essa lista não cresce com o número de clientes.

3. npm
npm publish --access public

Disponível em cdn.jsdelivr.net/npm/@bcvoz/webphone e unpkg.com logo após. Recomende aos integradores a versão travada — @bcvoz/webphone@0.7 — para que um major não quebre a página deles.

Pendente — descontinuar o pacote antigo. O @bravophone/webphone ainda não foi marcado como descontinuado. Rode isto só depois que npm view @bcvoz/webphone version responder, para o aviso não apontar para um pacote que ainda não existe:

npm deprecate @bravophone/webphone "Renomeado para @bcvoz/webphone. Troque o import e use window.BCVoz (window.Bravophone continua como alias)."

Não use npm unpublish: quem já instalou o pacote antigo continua funcionando e só passa a ver o aviso no npm install. Feito isso, apague esta nota.


Manter o cliente sempre atualizado

A intuição diz para usar a URL sem versão. É a pior escolha para isso, e os headers do CDN mostram por quê:

sem versão / @0.2   →  max-age=604800   (7 dias no navegador do usuário)
@0.2.1 exata        →  immutable        (eterno, mas fixo)

A URL sem versão é entregue com sete dias de cache na máquina de quem acessa. Publicar uma correção não alcança essa pessoa: npm run purge limpa as bordas do CDN, não o cache que já está no navegador dela.

O caminho que resolve é examples/loader-latest.js, que separa as duas coisas:

  1. pergunta ao CDN qual é a versão atual — resposta com 5 min de cache;
  2. carrega o bundle daquela versão exata — URL imutável, cache eterno.

Uma publicação chega em até cinco minutos, e o arquivo pesado vem de um cache que nunca precisa ser revalidado. O custo é uma requisição de ~1 kB antes do bundle, quase sempre servida do cache.

const { version } = await (await fetch(
  'https://data.jsdelivr.com/v1/packages/npm/@bcvoz/webphone/resolved'
)).json()

const s = document.createElement('script')
s.src = `https://cdn.jsdelivr.net/npm/@bcvoz/webphone@${version}/dist/bcvoz.umd.js`
document.head.appendChild(s)

Cole isso inline na página, não como arquivo externo — um arquivo externo teria o mesmo problema de cache que estamos evitando.

Se uma publicação não aparecer

Três caches diferentes, do mais provável ao menos:

O que está velho Como saber Solução
A consulta de versão BCVoz.version mostra a anterior Já resolvido: o snippet usa cache: 'no-store'
A página do integrador o próprio snippet mudou e não teve efeito Não sirva o HTML com max-age longo
O bundle Não acontece: a URL é versionada e immutable

Durante o desenvolvimento, Ctrl+Shift+R limpa os três de uma vez — foi o que funcionou nos primeiros testes. Em produção não há como pedir isso ao usuário, e é por isso que o no-store está na consulta: sem ele, quem carregou a página nos últimos cinco minutos continua na versão anterior.

Se ainda assim algo ficar para trás, npm run purge limpa as bordas do CDN — mas lembre que ele não alcança o navegador de ninguém.

Os assets do webphone acompanham automaticamente: o public_path é gravado com a versão do pacote, então carregar o SDK 0.2.1 carrega o host 0.2.1.

A sessão que o webphone espera

init() recebe a resposta do /api/voxfree/login inteira:

BCVoz.init({
  session: {
    vxToken:    '…',   // obrigatório
    expiresIn:  3600,  // segundos
    sip:        '…',   // sem isto o webphone não registra
    ramal:      '…',   // idem
    tenant:     '…',
    clienteId:  '…',
    ramaisUrl:  '…',

    // A segunda metade: sem ela o app fica na tela de login, mesmo com o
    // vxToken válido. O checkToken do webphone exige as duas.
    extension: { username: '…', password: '…', server: '…' },
  },
})

Onde a credencial SIP fica. O extension viaja apenas pela ponte (postMessage) e é aplicado no store em memória do webphone. Ele não entra no HTML do iframe nem no localStorage — a senha não fica legível no DOM da sua página. As outras sete chaves são pré-gravadas no storage, porque é de lá que o bundle as lê.

O SDK grava essas chaves onde o bundle as procura, antes dele avaliar — a sessão já sobe autenticada, sem piscar a tela de login.

token: '…' continua aceito como atalho para { vxToken }, mas sozinho não basta: o webphone carrega, não registra, e o RouteSelector avisa "faça login pelo webphone" — justamente o que a auto-autenticação existe para evitar.

Documentação

Documento Para quem
docs/api.html Referência completa da API — abra no navegador
docs/PARA-IA.md Contexto para um agente de IA implementar a integração
docs/PROMPT-INTEGRACAO.md Prompt pronto para colar num agente: integrar sempre na última versão, assíncrono, em qualquer stack
examples/integracao.html Exemplo pronto para colar numa página
examples/loader.js Carregar o SDK por JavaScript (SPA, Tag Manager)

API

BCVoz.init(options)
Opção Tipo Padrão Descrição
token string Token de sessão emitido pelo seu backend
mode 'srcdoc' | 'hosted' 'srcdoc' Como o webphone é carregado — ver abaixo
hostUrl string https://webphone.bravophone.com/ Origem do webphone (só no modo hosted)
position string 'bottom-right' Canto inicial
open boolean false Abrir já visível
launcher boolean true Exibir a aba lateral de abertura
launcherSide 'right' | 'left' 'right' Lado em que a aba fica colada
frame 'none' | 'bar' 'none' Moldura da janela — ver abaixo
dockTop 'max' | 'top-half' 'max' O que arrastar até a borda superior faz
title string 'BCVOZ' Texto da barra (só com frame: 'bar')
device { hostname?, user? } Identifica a máquina no REGISTER (X-Bravo-Device-*) — ver abaixo
Moldura: preservando 100% da UI

Por padrão (frame: 'none') não há barra de título — a UI do popup.js ocupa a janela inteira, exatamente como na extensão. Nenhum pixel é tomado.

O arraste continua funcionando porque a detecção do gesto acontece dentro do iframe, no guest-bridge.js: o host é cross-origin e não pode tocar naquele DOM, então o gesto viaja como delta pela ponte. Qualquer área que não seja botão, campo ou link arrasta a janela; o resto continua clicável, e uma seleção de texto em andamento nunca é sequestrada. As coordenadas usam screenX/screenY — absolutas na tela, imunes ao fato de o próprio iframe estar se movendo durante o arraste.

Um botão de fechar aparece sobreposto no canto ao passar o mouse, sem empurrar o conteúdo. Recolher para o launcher faz o papel de minimizar.

Use frame: 'bar' se preferir a barra com título, indicador de estado e controles.

Métodos

Janelashow() · hide() · toggle() · minimize(force?) · move(x, y) · resize(w, h) · dock(zone) · destroy() · isOpen · geometry

AparelhosetDevice({ hostname, user }). O webphone identifica o aparelho no REGISTER com três cabeçalhos opcionais: X-Bravo-Device-Id (um UUID gerado uma vez e guardado — o mesmo do heartbeat de presença), X-Bravo-Device-Hostname e X-Bravo-Device-User. É o que a tela "Aparelhos" de uma ligação mostra em vez de IP + nome do software. O id sai sozinho; hostname e usuário uma página não descobre, então quem embute num sistema interno informa pela opção device do init() ou por setDevice() a qualquer momento — se o ramal já registrou, o REGISTER é reenviado. O que faltar não vai (ausência é ausência, nunca vazio). user expõe o usuário a quem administra a conta: informe só se isso for aceitável para o seu caso.

Dois modos de carregar o webphone
BCVoz.init({ token })                     // hospedado (padrão)
BCVoz.init({ token, mode: 'srcdoc' })     // na origem do próprio site
srcdoc (padrão) hosted
Onde o iframe roda webphone.bravophone.com origem do próprio site
De onde vêm os arquivos do host do CDN, travados nesta versão
CORS dos backends uma origem fixa, não cresce uma entrada por integrador
Iframe de terceiro sim — sujeito a bloqueador e política não
Storage particionado sim (login por site do cliente) não
Você precisa manter o domínio do host nada

Antes de oferecer o srcdoc a um cliente, a origem dele precisa estar na allowlist de CORS de api.bravophone.com, pabx.teambravotech.com e devices.wavoip.com. Sem isso o webphone carrega, aparece na tela e não registra — o navegador descarta as respostas. Isso é trabalho no nosso backend: o integrador não tem como liberar CORS de um servidor que não é dele.

Há requests com withCredentials, então Access-Control-Allow-Origin: * não serve: a resposta precisa ecoar a origem exata mais Access-Control-Allow-Credentials: true. O caminho sustentável é a allowlist sair de banco, para entrar um cliente ser um registro e não um deploy em três serviços.

Do lado do integrador, o único requisito é o CSP admitir cdn.jsdelivr.net em script-src, style-src e font-src — a maioria dos sites não tem CSP restritivo e não precisa fazer nada.

O que foi verificado em navegador (examples/srcdoc-validation.html): getUserMedia funciona dentro do srcdoc sem allow=, a permissão é herdada do topo, enumerateDevices traz os rótulos, localStorage funciona, document.baseURI resolve para a página pai e @font-face com URL absoluta do CDN carrega.

A aba de abertura

Com a janela fechada, o webphone fica acessível por uma aba colada na lateral da viewport — não um botão circular solto no canto. Ela é arrastável na vertical e guarda a posição entre sessões.

No repouso mostra só o ícone. No hover (ou com foco de teclado) ela expande e revela a alça de pontinhos, sinalizando que dá para arrastar.

Um detalhe que decide se o componente é agradável ou irritante: arrastar não abre o webphone. O gesto vira arraste depois de 4px percorridos; abaixo disso continua sendo clique. Sem esse limiar, uma tremida de mouse no clique abriria a janela sem querer — ou pior, todo arraste terminaria abrindo.

A aba também responde a teclado (Enter / Espaço) e, numa chamada entrante, pulsa em vermelho com o contador — visível mesmo com a janela fechada.

BCVoz.init({ launcherSide: 'left' })   // cola do outro lado
BCVoz.setLauncherSide('right')         // troca em runtime
BCVoz.init({ launcher: false })        // sem aba: você controla com show()
Redimensionar e encaixar

A janela redimensiona por qualquer borda ou canto — as alças laterais são o que permite alargar a janela para o histórico de chamadas respirar. O teto é a viewport, não um valor fixo.

Dois comportamentos de encaixe, ambos com o mesmo vocabulário do Canva:

Arrastando, encostar numa região da viewport mostra uma prévia azul do encaixe antes de soltar:

Onde o cursor chega Encaixe
borda esquerda / direita altura cheia, largura mantida
borda inferior metade inferior, largura cheia
borda superior maximizado (configurável)
os quatro cantos meia tela esquerda/direita
qualquer outro lugar segue flutuando
 left-half │    max     │ right-half
 ──────────┼────────────┼──────────
   left    │  (flutua)  │   right
 ──────────┼────────────┼──────────
 left-half │bottom-half │ right-half

A borda superior é a única disputada: max é o gesto universal (Windows, macOS), mas quem trabalha com metades verticais costuma preferir a metade de cima ali. Daí a opção dockTop:

BCVoz.init({ dockTop: 'top-half' })   // topo encaixa na metade superior

Com ela, o máximo continua acessível por dock('max').

Arrastar uma janela encaixada de volta para o meio a solta e devolve o tamanho que ela tinha antes — e a janela nasce sob o cursor, proporcional a onde você a pegou, em vez de saltar.

Redimensionando, chegar a ~32px de uma borda da viewport completa até ela sozinha — o "completamento sugestivo".

Programaticamente:

BCVoz.dock('right')        // altura cheia à direita, largura mantida
BCVoz.dock('right-half')   // metade direita  (W/2 × altura cheia)
BCVoz.dock('bottom-half')  // metade inferior (largura cheia × H/2)
BCVoz.dock('top-half')     // metade superior
BCVoz.dock('bottom')       // metade inferior, mantendo a largura atual
BCVoz.dock('max')          // maximiza
BCVoz.dock('float')        // solta e restaura o tamanho anterior

BCVoz.on('resize', ({ width, height, dock }) => { /* … */ })

O encaixe persiste entre sessões e é recalculado para a viewport atual ao recarregar — uma janela docada ontem numa tela larga não volta com a geometria de ontem.

Telefonia — todos retornam Promise: call(number, meta?) · hangup() · answer() · mute(on?) · hold(on?) · sendDTMF(tone) · transfer(to) · getStatus() · setAuth(token) · logout()

Eventos
const off = BCVoz.on('call:incoming', (call) => { /* … */ })
off()  // remove o listener

ready · state · call:dialing · call:incoming · call:answered · call:ended · resize · reveal · open · close · error. Use '*' para receber todos como { event, payload }.

Uma chamada que não completa chega como call:ended: o estado do bundle não distingue desligar de falhar.


Requisitos

  • HTTPS obrigatório no site do cliente — getUserMedia só existe em secure context. localhost funciona no desenvolvimento; o SDK avisa no console se detectar contexto inseguro.
  • Navegadores com WebRTC e Shadow DOM: Chrome/Edge 88+, Firefox 90+, Safari 14+.
  • O site do cliente não pode ter um CSP frame-src que bloqueie webphone.bravophone.com — vale documentar isso no onboarding.

Licença

Software proprietário da BravoTech. Todos os direitos reservados. O pacote npm é publicado para consumo pelos integradores; o código deste repositório não é open source.

Keywords