--- # access-control.md # Access Control (RBAC) Checagens de **permissão e papel** para esconder ou bloquear ações que o usuário não pode executar. Enquanto o [`AuthGuard`](./auth.md) responde só "está logado?", o Access Control responde "esse usuário pode **excluir um post**?" — granularidade de `:`. !!! info "Por que isso existe além do `AuthGuard`?" `AuthGuard` é binário: autenticado ou não. Mas dentro de um app logado, um editor pode criar posts e um leitor não; um admin vê o botão de excluir e o resto não. Esconder esses controles na UI (e idealmente bloquear no backend) é trabalho de RBAC. O Access Control entrega um contrato plugável (`AccessControl`), uma estratégia pronta baseada em papéis, e os utilitários de UI (`useCan`, ``) para amarrar tudo. !!! warning "RBAC no frontend é UX, não segurança" Esconder um botão **não** protege o endpoint. A decisão de permissão que importa acontece no backend (no `tempest-fastapi-sdk`). O Access Control aqui melhora a experiência — evita mostrar ações que vão falhar com 403 — mas o servidor continua sendo a fonte da verdade. ## Quando usar - Esconder/desabilitar botões e links conforme a permissão (``). - Ramificar lógica por permissão dentro de um componente (`useCan`). - Derivar o conjunto de permissões do usuário a partir do JWT (`permissionsFromToken`). ## Provider — `` Envolva o app com ``, passando uma estratégia `AccessControl`. Toda checagem (`useCan`, ``) lê dessa estratégia via contexto: ```tsx import { AccessControlProvider, createRoleAccessControl } from "tempest-react-sdk"; import { App } from "./App"; const accessControl = createRoleAccessControl({ role: "editor", roles: { editor: ["posts:create", "posts:update", "comments:read"], admin: ["*"], }, }); export function Root() { return ( ); } ``` !!! note "Sem provider → libera tudo" Se **nenhum** `` estiver acima na árvore, `useCan`/`` tratam toda checagem como **permitida**. Isso mantém o SDK opt-in: você liga a aplicação dropando um provider, e desliga removendo-o. Útil em dev ou em apps sem RBAC ainda — os componentes que usam `` continuam funcionando, só não bloqueiam nada. ## `createRoleAccessControl` Constrói uma estratégia RBAC a partir de um conjunto estático de permissões. A assinatura é `createRoleAccessControl({ permissions?, roles?, role? })`: - `permissions` — strings concedidas diretamente, independente de papel. - `roles` — mapa `nome do papel → permissões` que aquele papel concede. - `role` — o(s) papel(éis) ativo(s) (string ou array). As permissões deles (de `roles`) são mescladas. O conjunto efetivo é `permissions` **mais**, para cada papel ativo em `role`, as permissões listadas em `roles[role]`. ### Regras de matching Cada permissão é uma string `":"` (ex.: `"posts:create"`) ou um `""` sozinho. Os curingas: | Permissão concedida | O que libera | | ------------------- | -------------------------------------------------------- | | `"*"` | **tudo** — qualquer action em qualquer resource | | `"posts:*"` | qualquer action no resource `posts` | | `"posts:create"` | exatamente a action `create` no resource `posts` | | `"export"` | a action global `export` (checagem **sem** `resource`) | ```ts import { createRoleAccessControl } from "tempest-react-sdk"; const ac = createRoleAccessControl({ role: "editor", roles: { editor: ["posts:*", "comments:read"] }, }); ac.can({ action: "create", resource: "posts" }); // { can: true } — bate em "posts:*" ac.can({ action: "read", resource: "comments" }); // { can: true } — bate exato ac.can({ action: "delete", resource: "users" }); // { can: false, reason: "missing permission" } ``` !!! tip "Combine `permissions` e `roles`" `permissions` é para concessões diretas (um override pontual num usuário específico), `roles` + `role` é para o caso comum baseado em papel. Os dois se somam — uma permissão direta vale mesmo que nenhum papel a conceda. ## `permissionsFromToken` — permissões a partir do JWT Em vez de manter a lista de permissões à mão, derive-a do JWT do usuário. `permissionsFromToken(token, { claim })`: - Lê a claim configurada (default `"permissions"`). - Se ausente, cai para as claims OAuth `"scopes"` e depois `"scope"`. - Claim em array é usada como está; claim em string é quebrada por espaços (convenção de `scope` OAuth). - Retorna `[]` em qualquer falha de decode ou quando nenhuma claim reconhecível existe. ```ts import { createRoleAccessControl, permissionsFromToken } from "tempest-react-sdk"; const token = getAccessTokenFromSomewhere(); // Lê a claim "permissions" (default), caindo para "scopes"/"scope" const permissions = permissionsFromToken(token); const accessControl = createRoleAccessControl({ permissions }); ``` !!! warning "`permissionsFromToken` não valida assinatura" Ele só decodifica o payload do JWT (via `decodeJWT`) para ler a claim — **não** verifica a assinatura. É leitura defensiva para UX, igual ao [`decodeJWT`](./auth.md). Confiar nessas permissões para segurança real é trabalho do backend. Para uma claim custom (ex.: o backend emite `"perms"`): ```ts const permissions = permissionsFromToken(token, { claim: "perms" }); ``` ## `useCan` — checagem programática `useCan({ action, resource })` resolve a checagem contra a estratégia em contexto e devolve `{ allowed, isLoading, reason }`. Funciona com `can` síncrono (boolean ou `CanResult`) e com `Promise` (políticas remotas), re-rodando quando os params mudam: ```tsx import { useCan } from "tempest-react-sdk"; export function PostActions({ postId }: { postId: string }) { const { allowed, isLoading } = useCan({ action: "update", resource: "posts" }); if (isLoading) return null; // checagem assíncrona em andamento return ( ); } ``` !!! note "`reason` explica o `false`" Quando `allowed` é `false`, `reason` costuma trazer o motivo (`"missing permission"` vindo do `createRoleAccessControl`, ou a mensagem de erro de uma política async que rejeitou). Útil para tooltip ("Você não tem permissão para X") em vez de só esconder. ## `` — render condicional `` renderiza `children` quando a action é permitida, senão o `fallback` (ou nada). Enquanto uma checagem async está pendente, renderiza o `fallback` (ou nada): ```tsx import { Can } from "tempest-react-sdk"; Sem permissão para criar.

}>
; ``` ## `useAccessControl` — a estratégia crua Quando você precisa da estratégia em si (chamar `can` fora de render, checar várias permissões num loop, decidir uma rota antes de montar a árvore), `useAccessControl()` devolve o `AccessControl` do contexto — ou `null` quando não há provider: ```tsx import { useAccessControl } from "tempest-react-sdk"; function useBulkPermissions(ids: string[]) { const control = useAccessControl(); return ids.filter((id) => control?.can({ action: "delete", resource: "posts", params: { id } }) ?? true); } ``` !!! danger "`null` significa **permitir**, não negar" Sem provider na árvore, `useAccessControl()` devolve `null` — e a convenção do SDK é tratar isso como "libera tudo", igual ao `useCan`/``. Escrever `control?.can(...) ?? false` inverte a regra e trava o app inteiro em qualquer ambiente que ainda não montou o provider. O `?? true` do exemplo não é descuido. ## Exemplo completo — botão "Excluir" protegido Tudo junto: deriva permissões do JWT, configura a estratégia, e protege a ação de excluir tanto por render (``) quanto por estado desabilitado (`useCan`): ```tsx import { AccessControlProvider, Can, createRoleAccessControl, permissionsFromToken, useCan, } from "tempest-react-sdk"; // 1. Estratégia derivada do JWT do usuário logado. function buildAccessControl(token: string) { return createRoleAccessControl({ // ex.: token com { permissions: ["posts:read", "posts:delete"] } permissions: permissionsFromToken(token), }); } // 2. Provider no topo do app. export function Root({ token }: { token: string }) { return ( ); } // 3a. Esconder a ação inteira com . function PostRow({ id, title }: { id: string; title: string }) { return (
{title}
); } // 3b. Mostrar sempre, mas desabilitar + explicar com useCan. function DeleteButton({ id }: { id: string }) { const { allowed, isLoading, reason } = useCan({ action: "delete", resource: "posts" }); return ( ); } ``` !!! tip "`` esconde; `useCan` desabilita" Use `` quando a ação simplesmente não deve existir para quem não pode (limpa a UI). Use `useCan` quando você quer manter o controle visível mas inerte, com um `title`/tooltip explicando o porquê — útil para descoberta ("isso existe, mas precisa de outro papel"). ## Recap - `` injeta uma estratégia `AccessControl`; **sem provider, toda checagem libera** (opt-in). - `createRoleAccessControl({ permissions, roles, role })` monta RBAC estático; matching por `":"`, com curingas `"*"` (tudo) e `":*"` (resource inteiro). - `permissionsFromToken(token, { claim })` lê permissões do JWT (claim default `"permissions"`, fallback `"scopes"`/`"scope"`); não valida assinatura. - `useCan({ action, resource })` → `{ allowed, isLoading, reason }`, com suporte a checagens síncronas e assíncronas. - `` esconde a UI; `useCan` desabilita e explica com `reason`. - RBAC no frontend é UX — o backend continua sendo a fonte da verdade da autorização. ## Veja também - [Auth + Guard](./auth.md) — `AuthGuard` (autenticado?) e `decodeJWT`/`isJWTExpired` - [Data Provider](./data-provider.md) — proteja as ações de CRUD por papel/permissão --- # app-providers.md # AppProviders Todo app React precisa de um punhado de _providers_ no topo da árvore: cache de dados, tema, internacionalização, captura de erros. `` reúne todos eles em **um único bloco declarativo** — você diz o que quer ligado e como configurar, e o SDK monta a pirâmide na ordem certa pra você. 🚀 ## O problema: a pirâmide de providers Sem o ``, a raiz da sua aplicação costuma virar uma pirâmide aninhada à mão. Você precisa lembrar **quais** providers existem, **a ordem** certa de aninhamento e repetir isso em todo projeto: ```tsx // App.tsx — montagem manual (o que queremos evitar) import { ErrorBoundary, QueryProvider, ThemeProvider, I18nProvider, AppRouter, } from "tempest-react-sdk"; import { routes } from "@/routes"; export function App() { return ( Something went wrong.

}> Loading…

} />
); } ``` Funciona, mas é frágil: a ordem importa (o `ErrorBoundary` precisa ficar por fora pra capturar erros dos providers internos), o aninhamento cresce em diagonal e cada app reescreve a mesma estrutura. !!! note "Os providers continuam existindo isolados" `QueryProvider`, `ThemeProvider`, `I18nProvider` e `ErrorBoundary` seguem exportados individualmente — use-os direto quando precisar de controle fino. O `` é só a conveniência que conecta os quatro pra você. ## A solução: um bloco só ```tsx // App.tsx import { AppProviders, AppRouter } from "tempest-react-sdk"; import { routes } from "@/routes"; export function App() { return ( Something went wrong.

}}> Loading…

} />
); } ``` É isso. O `` aninha tudo de fora pra dentro nesta ordem: ```text ErrorBoundary → QueryProvider → ThemeProvider → I18nProvider → children ``` Repare que **Query e Theme já vêm ligados** com os defaults do SDK — você não precisou configurar nada. Só pedimos o `errorBoundary` porque ele é opcional (veja abaixo). !!! tip "Onde o `` mora" Os providers ficam **por fora** do roteamento; o `` (e portanto todas as suas rotas) fica **por dentro**. Assim cada página tem acesso a cache, tema e i18n, e qualquer erro de renderização de qualquer rota cai no `ErrorBoundary`. Veja a página de roteamento pra detalhes do ``. ## O que vem ligado por padrão vs. opt-in | Prop | Estado padrão | Como ligar / configurar | | --------------- | ------------------ | ---------------------------------------- | | `query` | **Ligado** | Já ativo. Passe um objeto pra ajustar. | | `theme` | **Ligado** | Já ativo. Passe um objeto pra ajustar. | | `i18n` | Desligado (opt-in) | Passe `{ locale, messages }` pra montar. | | `errorBoundary` | Desligado (opt-in) | Passe `{ fallback }` pra montar. | - **`query`** e **`theme`** já estão ativos com os defaults do SDK — o caso comum (você quer cache de dados e tema) não exige nenhuma configuração. - **`i18n`** e **`errorBoundary`** só entram na árvore quando você passa a prop correspondente. Omitiu? O provider simplesmente não é montado. !!! info "Defaults de query" Quando ligado por padrão, o `QueryProvider` usa: `staleTime` de 5 minutos, `gcTime` de 30 minutos, `retry: 1` e `refetchOnWindowFocus: false`. ## Desligando um padrão com `false` Às vezes o app já monta o seu próprio `QueryClient`, ou você não quer o tema do SDK. Passe `false` na prop pra **remover aquele provider** da árvore: ```tsx // query e theme desligados — o app monta os seus por fora import { AppProviders } from "tempest-react-sdk"; export function App() { return ( ); } ``` !!! warning "`false` remove o provider — não o desativa silenciosamente" Com `query={false}`, nenhum `QueryClient` é montado pelo ``. Se algum componente filho usar `useQuery`, ele precisa de um provider montado por você mais acima na árvore — senão o React Query lança erro em runtime. ## Ajustando cada provider Cada prop aceita um objeto com as mesmas opções do provider isolado (menos `children`, que o `` controla). ### Query ```tsx import { AppProviders } from "tempest-react-sdk"; ; ``` Você também pode passar um `client` (`QueryClient`) já configurado em vez de `defaultOptions`. ### Theme ```tsx import { AppProviders } from "tempest-react-sdk"; ; ``` ### i18n ```tsx import { AppProviders } from "tempest-react-sdk"; const messages = { "pt-BR": { hello: "Olá" }, "en-US": { hello: "Hello" }, }; ; ``` `i18n` aceita `locale`, `messages`, e opcionalmente `fallbackLocale` e `storageKey`. ## A fallback do ErrorBoundary A prop `errorBoundary` aceita `fallback` em duas formas: um `ReactNode` fixo, ou uma **função de render** que recebe `{ error, reset }` — útil pra mostrar a mensagem do erro e oferecer um botão de "tentar de novo": ```tsx import { AppProviders } from "tempest-react-sdk"; , }} > ; ``` Além de `fallback`, você pode passar `onError` (callback ao capturar) e `resetKeys` (valores que, ao mudarem, resetam o boundary automaticamente). ## Exemplo completo: todas as props juntas Aqui está tudo em ação — query ajustada, tema desligado, i18n montado e error boundary com função de render: ```tsx import { AppProviders } from "tempest-react-sdk"; const messages = { "pt-BR": { hello: "Olá" }, "en-US": { hello: "Hello" }, }; export function Root() { return ( , }} > ); } ``` ## Recap - `` substitui a pirâmide manual de providers por **um bloco declarativo**, aninhando de fora pra dentro: `ErrorBoundary → QueryProvider → ThemeProvider → I18nProvider → children`. ✅ - **`query` e `theme` vêm ligados** com os defaults do SDK; **`i18n` e `errorBoundary` são opt-in** — só montam quando você passa a prop. - Passe `false` em `query` ou `theme` pra **remover** aquele provider (quando o app monta o seu próprio). - Passe um **objeto** em qualquer prop pra ajustar o provider correspondente. - A `fallback` do error boundary pode ser um `ReactNode` ou uma **função** que recebe `{ error, reset }`. - Coloque o `` **por fora** do ``: providers fora, rotas dentro. 💡 --- # architecture.md # Arquitetura O `tempest-react-sdk` é um pacote único com camadas independentes. O consumidor importa só o que usa; tudo é externalizado no bundle do SDK, então o bundler do app faz tree-shake do que não é referenciado. !!! info "Esta página é a arquitetura **do pacote**" Aqui você aprende como o SDK é montado — camadas, dependências, subpaths, bundle. Se o que você quer é como organizar o **seu app** (camadas, pastas, onde mora cada estado, limites de arquivo), a página é [Camadas de um app frontend](./design/architecture.md), na aba [Design de Software](./design/index.md). !!! tip "Importe só o que usa" Não existe penalidade por o SDK ser grande. Cada camada (HTTP, auth, query, forms…) é independente — se você nunca importa `createOfflineStore`, o `dexie` não entra no seu bundle. Comece com um `Button` e cresça a partir daí. > Diagrama editável: [architecture.drawio](./diagrams/architecture.drawio) (abra no [draw.io](https://app.diagrams.net)). ## Escopo: só client-side O SDK é feito para **SPA client-rendered com capacidade offline** — service worker, outbox no IndexedDB, prompt de instalação, background sync. Ele **não** suporta SSR nem React Server Components: nenhum módulo declara `"use client"` e os componentes assumem que montam num browser. O App Router do Next não é alvo. !!! warning "Isso é escolha de escopo, não lacuna" Cobrir os dois mundos custaria em cada API (dois caminhos de render, hidratação, `window` proibido no topo do módulo) e o offline-first — a razão de existir do pacote — sairia pior. Os guards `typeof window === "undefined"` que existem nos hooks servem pra não explodir fora do browser (testes em Node, contexto de service worker, plugin de build), não pra prometer render no servidor. ## Camadas ### Fundação de aplicação A base opinativa que monta um app React inteiro. É o que a CLI [`create-tempest-app`](./scaffold.md) gera. | Camada | O que faz | Página | | ---------------------- | ------------------------------------------------------------------------------------- | -------------------------------- | | **Vite (`vite/`)** | `createViteConfig` — plugin React + alias `@` → `src` + dev server (subpath `/vite`). | [Vite & alias](./vite-config.md) | | **Router (`router/`)** | `defineRoutes`, ``, `` + re-exports do React Router v8. | [Roteamento](./routing.md) | | **Store (`store/`)** | `createStore`, `createSelectors` (fábricas Zustand genéricas). | [Estado](./state.md) | | **App (`app/`)** | `` — compõe ErrorBoundary → Query → Theme → i18n num bloco. | [Providers](./app-providers.md) | ### Blocos de UI e integrações | Camada | O que faz | | ----------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------- | | **Componentes (`components/`)** | 70+ UI primitives (Button, Input, Modal, Table, DataTable, Command, Calendar…) com CSS Modules prefixados `tempest_`. | | **Hooks (`hooks/`)** | `useDebounce`, `usePagination`, `useMediaQuery`, `useKeyboardShortcut`, `useFocusTrap`… | | **HTTP (`http/`)** | `createApiClient`, `parseResponse`, `uploadWithProgress`, `retry`, `usePoll`. | | **Auth (`auth/`)** | `createAuthStore` (Zustand) + `AuthGuard` + JWT helpers + `lazyWithRetry`. | | **Query (`query/`)** | `QueryProvider`, `createQueryKeys`, presets de tempo. | | **SSE / WebSocket / Push / SW** | Transportes em tempo real com reconnect. | | **Offline (`offline/`)** | `createOfflineStore` (Dexie). | | **Forms (`forms/`)** | `useZodForm`, `zodResolver`, `FormField`, inputs mascarados BR. | | **Theme / i18n / Logger / Telemetry / Feature Flags** | Tema (no-flash), i18n in-house, logger leveled, adapters injetáveis. | | **Utils (`utils/`)** | `cn`, format BR, arrays/objects/guards/functions/promises, strings, numbers, `randomId`. | ## Dependências **`react`**, **`react-dom`** e **`react-router`** são **peer dependencies** — os três carregam contexto React, e uma segunda cópia não é peso extra no bundle, é uma segunda *instância* que quebra em runtime. Todo o resto é **dependência direta** — instalada automaticamente por `npm install tempest-react-sdk` e externalizada no bundle (o bundler do app resolve do `node_modules` e faz tree-shake). | Pacote | Status | Usado por | | ------------------------------ | ------------------- | ---------------------------------------------------------------------------------------- | | `react`, `react-dom` | **Peer (obrigat.)** | Tudo | | `react-router` (`^7 \|\| ^8`) | **Peer (obrigat.)** | `AppRouter`, `defineRoutes`, `RouteGuard`, re-exports | | `zustand` | Dep direta | `createStore`, `createSelectors`, `createAuthStore` | | `@tanstack/react-query` | Dep direta | `QueryProvider`, `createQueryKeys`, `AppProviders` | | `zod` | Dep direta | `parseResponse`, `validateForm`, `zodResolver`, `useZodForm` | | `react-hook-form` | Dep direta | `useZodForm`, `FormField`, inputs mascarados | | `dexie` | Dep direta | `createOfflineStore` | | `lucide-react` | Dep direta | Ícones (`leftIcon`/`rightIcon`) | | `vite`, `@vitejs/plugin-react` | **Peer opcional** | `createViteConfig` (subpath `tempest-react-sdk/vite`) — já presente em qualquer app Vite | !!! warning "Por que `react-router` é peer, e não dep direta" Ele guarda contexto React. Uma cópia aninhada em `tempest-react-sdk/node_modules` é um `` **diferente** do que o seu app renderiza, então qualquer hook do SDK que alcance esse contexto estoura com `useNavigate() may be used only in the context of a ` — crash de runtime, não regressão de tamanho. É a mesma razão de `react` ser peer, e é a única exceção à regra "todo o resto é dep direta". O range `^7 || ^8` deixa o app em qualquer um dos dois majors instalar uma cópia só: a superfície re-exportada é idêntica nas duas versões, e ambas entregam os bindings de DOM dentro do próprio `react-router` (não existe `react-router-dom` separado). !!! note "O resto continua dep direta" `zustand`, `zod`, `dexie`, `react-hook-form`, `@tanstack/react-query` e `lucide-react` são dependências diretas — `npm install tempest-react-sdk` já traz tudo, sem você listar nada à mão. Duas cópias dessas custam bytes, não correção. Adapters de SDKs externos (Sentry, PostHog, GrowthBook, LaunchDarkly) **não** são declarados — o caller injeta a instância na factory. ## Subpaths | Import | Conteúdo | | ------------------------------ | ----------------------------------------------------- | | `tempest-react-sdk` | Barrel principal (componentes, hooks, foundation…). | | `tempest-react-sdk/styles.css` | Tokens `--tempest-*` + reset + CSS Modules. | | `tempest-react-sdk/vite` | `createViteConfig` (Node-only, pro `vite.config.ts`). | | `tempest-react-sdk/testing` | `createMockHandlers` (helpers MSW pra testes). | | `tempest-react-sdk/icons` | `Icon` por slug + registro estático ([Ícones](./icons.md)). | ## Bundle Vite library mode → ESM (`tempest-react-sdk.js`) + CJS (`.cjs`) + `.d.ts` rollupado + `styles.css` (CSS Modules num arquivo só, `cssCodeSplit: false`). Orçamento monitorado por `size-limit` no CI. ## Recap - Um pacote, camadas independentes; você importa só o que usa e o bundler faz tree-shake do resto. - Só `react` + `react-dom` são peers; as demais libs são deps diretas instaladas junto. - Subpaths: o barrel principal, `…/styles.css`, `…/vite` (Node-only), `…/testing` e `…/icons` (ícone por slug). - A fundação de app ([Vite](./vite-config.md) · [Router](./routing.md) · [Store](./state.md) · [Providers](./app-providers.md)) é o que o [`create-tempest-app`](./scaffold.md) monta pra você. ## Veja também - [Design de Software — camadas do seu app](./design/architecture.md) - [Scaffold — `create-tempest-app`](./scaffold.md) - [HTTP — fluxo de request](./http.md) - Diagrama: [architecture.drawio](./diagrams/architecture.drawio) --- # audio.md # Áudio Áudio no navegador, nas duas direções. **Reprodução** — notificação sonora (chime de mensagem, confirmação de pagamento) com `playAudio`, `useAudio` e `createAudioPlayer`, e o componente `AudioPlayer` quando você precisa de transporte (play/pause, barra, tempos). **Captura** — `AudioRecorder` pra uma nota de voz em uma linha, e por baixo `useMediaPermission`, `useMediaDevices`, `useMicrophone`, `useAudioRecorder`, `createLevelMeter` e `blobToWav`. Nenhuma dependência nova: a fatia inteira mede **5,50 KB brotli**. !!! tip "Se você só quer gravar uma nota de voz, pule para [Gravação](#gravacao-comece-pelo-componente)" O resto da página é a camada de baixo, pra quando o componente não serve. !!! info "Por que um wrapper em volta de `new Audio()`?" Tocar som no navegador esbarra na _autoplay policy_ e em vazamento de elementos `Audio`. O SDK encapsula: rastreia o clipe atual (pra dar `stop`), normaliza volume, trata o bloqueio de autoplay devolvendo `null` em vez de estourar, e limpa no unmount quando você usa o hook. ## `playAudio` — one-off no player compartilhado Ideal pra um som disparado por um evento, sem estado de UI: ```tsx import { playAudio, useEventStream } from "tempest-react-sdk"; interface StreamEvent { type: "NOTIFY" | "PAYMENT-SUCCESS"; } export function PaymentSounds() { useEventStream(`${import.meta.env.VITE_API_URL}/notifications`, { onMessage: ({ data }) => { if (data.type === "PAYMENT-SUCCESS") { void playAudio("/audio/dinheiro.mp3", { volume: 0.5 }); } }, }); return null; } ``` `playAudio(src, options)` retorna `Promise` — `null` quando o navegador bloqueou o autoplay. Opções: `volume` (0–1, default 1), `loop`, `autoplay`, `stopPrevious`, `onEnded`, `onError`. Pra parar o que o player compartilhado está tocando, use `stopAudio()`. ## `useAudio` — player privado por componente Cada instância do hook tem seu próprio player, então desmontar para o áudio automaticamente: ```tsx import { useAudio } from "tempest-react-sdk"; export function NotificationBell() { const audio = useAudio(); return ( ); } ``` - `audio.play(src, options)` — toca no player privado (mesmas options do `playAudio`). - `audio.stop()` — para o clipe atual. - `audio.unlocked` — vira `true` após o primeiro `play()` bem-sucedido. Útil pra esconder UI que pede a interação inicial. - Cleanup automático no unmount. !!! tip "Use `unlocked` pra guiar o usuário" Antes do primeiro clique, o navegador bloqueia áudio. Mostre uma dica ("toque pra ativar som") enquanto `unlocked === false` e esconda assim que ele virar `true`. ## `createAudioPlayer` — canais isolados `createAudioPlayer()` cria um tracker independente do default. Use quando precisar tocar dois sons simultaneamente sem que um corte o outro (ex.: música de fundo + efeito sonoro): ```ts import { createAudioPlayer } from "tempest-react-sdk"; const music = createAudioPlayer(); const sfx = createAudioPlayer(); await music.play("/audio/loop.mp3", { loop: true, volume: 0.3 }); await sfx.play("/audio/coin.wav", { volume: 1 }); // não corta a música music.stop(); // para só a música console.log(sfx.current()); // HTMLAudioElement | null ``` Cada player rastreia **um** clipe atual. `stopPrevious: true` no `play()` para o clipe anterior daquele mesmo player antes de tocar o novo. ## `createSfxPool` / `useSfxPool` — efeitos sonoros curtos `new Audio(src)` a cada disparo aloca um elemento e re-entra na pilha de rede por um arquivo que o navegador já tem. Para um som que toca dezenas de vezes por minuto — um blip de menu, um hit, um pickup — é a forma errada. O pool aloca uma vez por fonte e reproduz. ```ts import { useSfxPool } from "tempest-react-sdk"; function Menu({ sfxVolume }: { sfxVolume: number }) { const sfx = useSfxPool({ volume: sfxVolume / 100, baseUrl: import.meta.env.BASE_URL }); useEffect(() => { sfx.preload(["sfx/move.mp3", "sfx/select.mp3", "sfx/back.mp3"]); }, [sfx]); return ; } ``` - `play(src, { volume })` — o volume por play é multiplicado pelo master do pool. - `preload(src | src[])` — busca antes do primeiro disparo, pra ele não sair mudo enquanto o arquivo baixa. - `setVolume(v)` — aplica também no que já está soando, **reescalando pelo ganho de cada um**: um clipe que começou a meio volume não é puxado pra cima. - `stop(src?)` — para uma fonte, ou todas. - `dispose()` — libera tudo. O `useSfxPool` já chama no unmount. !!! note "`voices`: repetir ou sobrepor" O default (`voices: 1`) **reinicia** o clipe a cada play, que é o que um blip de menu quer. Suba pra deixar o som se sobrepor — um hit tocando enquanto o anterior ainda ressoa: ```ts const hits = createSfxPool({ voices: 3 }); ``` !!! tip "Não confunda com `createAudioPlayer`" `createAudioPlayer` rastreia **um** clipe atual, com loop, roteamento de saída e callbacks de ciclo de vida — é o que música de fundo precisa. Efeitos são o caso oposto: muitas fontes, todas curtas, dispara-e-esquece, e o que importa é que disparar seja barato. Mudar `volume` no `useSfxPool` chama `setVolume` no pool existente em vez de recriá-lo — recriar jogaria fora todo elemento que o usuário já baixou, que é exatamente o custo que o pool existe pra evitar. `baseUrl`, `voices` e `maxSources` são lidos uma vez, na criação. ## Autoplay policy Navegadores bloqueiam playback antes da primeira interação do usuário. `playAudio` / `play()` retornam `null` quando bloqueado (e chamam `onError` se passado) — em vez de lançar. !!! warning "Destrave o áudio no primeiro clique" Não dá pra tocar som antes de qualquer interação. Desenhe o app pra disparar um `play()` (mesmo de um clipe silencioso curto) no primeiro clique de qualquer botão; a partir daí o navegador libera os próximos. ## Assets O SDK **não** embute áudios. Sirva em `/audio/*` (ou CDN) e passe a URL. Inspiração de paleta sonora (alofans): ```ts export const AUDIOS = { plim: "/audio/plim.wav", dinheiro: "/audio/dinheiro.mp3", notification: "/audio/bell_sound.wav", }; ``` ## Gravação: comece pelo componente Se você só quer uma nota de voz, é uma linha. O `AudioRecorder` cuida da permissão, do medidor de nível, do relógio e da revisão antes de você receber o áudio: ```tsx import { AudioRecorder } from "tempest-react-sdk"; export function NotaDeVoz({ ticketId }: { ticketId: string }) { return ( { const form = new FormData(); form.append("audio", blob, `nota.${mimeType.includes("mp4") ? "m4a" : "webm"}`); form.append("duracao", String(durationMs)); void fetch(`/api/tickets/${ticketId}/audio`, { method: "POST", body: form }); }} footer={Máximo 2 minutos.} /> ); } ``` O que você ganha sem escrever nada: | Você fez | O componente faz | | --- | --- | | nada | **Não** pede o microfone no mount — só no primeiro toque em Gravar | | nada | Se a permissão já está `denied`, diz isso e como resolver, em vez de oferecer um botão que não funciona | | nada | Medidor de nível ao vivo, relógio que **desconta pausa**, pausar/continuar | | `maxDurationMs` | Para sozinho no limite, e mostra o limite ao lado do relógio | | nada | Player de revisão com transporte, e "Gravar de novo" reaproveitando o mesmo stream | | `format="wav"` | Converte antes de te entregar, então o `onRecorded` **sempre** dá o formato que você pediu | !!! danger "O prompt de permissão não é disparado no mount, e isso é a decisão mais importante da página" Um prompt que o usuário não provocou é a forma mais confiável de ganhar um **Block permanente** — e depois disso o `getUserMedia` rejeita **sem nunca mais perguntar**. Por isso o microfone abre no primeiro toque em Gravar, e o toque sobrevive ao round-trip: o componente arma a gravação e começa quando o stream chega. Esperar um segundo clique faria o primeiro parecer quebrado. ### Props | Prop | Tipo | Default | O que faz | | --- | --- | --- | --- | | `onRecorded` | `(recording: AudioRecording) => void` | — | Recebe o áudio pronto. Não dispara em cancelamento. | | `maxDurationMs` | `number` | — | Para sozinho. **Vale sempre setar** em tela pública. | | `deviceId` | `string` | — | Microfone específico, de `useMediaDevices().audioInputs`. | | `format` | `"native" \| "wav"` | `"native"` | `"wav"` converte no stop. Veja o custo abaixo. | | `wavOptions` | `WavOptions` | — | `{ mono: true, sampleRate: 16000 }` serve pra fala. | | `audioBitsPerSecond` | `number` | — | 32000–64000 basta pra voz em Opus. | | `review` | `boolean` | `true` | Player de revisão antes de entregar. | | `locale` | `"pt-BR" \| "en"` | `"pt-BR"` | Rótulos. | | `footer` | `ReactNode` | — | Dica, contagem, aviso legal. | | `onError` | `(error: unknown) => void` | — | Falha do gravador, ou conversão WAV que não deu. | `AudioRecording = { blob, mimeType, durationMs }`. ## `AudioPlayer` — transporte pra um clipe Aceita `Blob` direto, porque o que um app mais toca é a gravação que ele acabou de fazer: ```tsx ``` !!! warning "Passe `durationMs` sempre que tiver — o `