From 7b7bfc5be6166cad1c3f2f1646bffdf40d92042a Mon Sep 17 00:00:00 2001 From: in100tiva Date: Wed, 30 Sep 2026 12:45:29 -0300 Subject: [PATCH] feat(i18n): add Brazilian Portuguese (pt-BR) interface language MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a third interface language next to English and Traditional Chinese. The pt-BR catalog translates the same 448 English source keys as zh-TW, keeps every {placeholder} intact and is wired through the existing locale cookie, server action and provider — no new dependency, schema or worker change. A `satisfies` check makes a missing pt-BR key a compile error, and a test pins the pt-BR key set to the zh-TW one so the catalogs cannot drift apart. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01PEPFq6M4tCRdtCgXbnRt5a --- README.md | 2 +- __tests__/i18n-actions.test.ts | 7 + __tests__/i18n.test.ts | 36 ++- components/language-switcher.tsx | 3 + docs/localization.md | 10 +- lib/i18n/index.ts | 15 +- lib/i18n/pt-BR.json | 450 +++++++++++++++++++++++++++++++ 7 files changed, 510 insertions(+), 13 deletions(-) create mode 100644 lib/i18n/pt-BR.json diff --git a/README.md b/README.md index 75f80d366..3810c9b0a 100644 --- a/README.md +++ b/README.md @@ -43,7 +43,7 @@ OpenReply is built around Meta's official Instagram private replies. It does not - Multiple Instagram accounts. Connect several professional accounts under one workspace, each with its own limits. - Workspaces and roles. Owner, admin, and member roles with invite links, useful if you run this for clients. - Campaign templates. Start from a preset instead of a blank form. -- English and Traditional Chinese interface, with a saved language preference. See [interface languages](docs/localization.md). +- English, Traditional Chinese and Brazilian Portuguese interface, with a saved language preference. See [interface languages](docs/localization.md). - Inbox. Read your Instagram DM conversations and reply from the dashboard, inside Meta's 24-hour messaging window. Cached so it loads instantly on repeat visits. - DM logs. Every send, skip, and failure is logged with a reason. - Self-comment filtering. Your own comments never trigger a reply, since Meta rejects DMing yourself anyway. diff --git a/__tests__/i18n-actions.test.ts b/__tests__/i18n-actions.test.ts index 8def0585d..46ab46f53 100644 --- a/__tests__/i18n-actions.test.ts +++ b/__tests__/i18n-actions.test.ts @@ -19,6 +19,13 @@ describe("language preference", () => { expect(t("Settings")).toBe("設定"); }); + it("renders Brazilian Portuguese from the saved cookie", async () => { + store.get.mockReturnValue({ value: "pt-BR" }); + const { locale, t } = await getI18n(); + expect(locale).toBe("pt-BR"); + expect(t("Settings")).toBe("Configurações"); + }); + it("falls back to English for an invalid cookie", async () => { store.get.mockReturnValue({ value: "unsupported" }); expect((await getI18n()).locale).toBe("en"); diff --git a/__tests__/i18n.test.ts b/__tests__/i18n.test.ts index c7573f94d..3069190fd 100644 --- a/__tests__/i18n.test.ts +++ b/__tests__/i18n.test.ts @@ -1,13 +1,15 @@ import { describe, expect, it } from "vitest"; import { createI18n, resolveLocale } from "../lib/i18n"; +import ptBR from "../lib/i18n/pt-BR.json"; import zhTW from "../lib/i18n/zh-TW.json"; describe("interface translations", () => { it("keeps English as the default for absent or unsupported preferences", () => { - for (const value of [undefined, null, "", "fr", "zh-CN", "../zh-TW"]) { + for (const value of [undefined, null, "", "fr", "zh-CN", "../zh-TW", "pt", "pt-PT"]) { expect(resolveLocale(value)).toBe("en"); } expect(resolveLocale("zh-TW")).toBe("zh-TW"); + expect(resolveLocale("pt-BR")).toBe("pt-BR"); }); it("renders both interface languages from the same keys", () => { @@ -49,10 +51,34 @@ describe("interface translations", () => { it("has complete, plain-text translations with matching interpolation fields", () => { const placeholders = (text: string) => [...text.matchAll(/\{(\w+)\}/g)].map((match) => match[1]).sort(); - for (const [source, translation] of Object.entries(zhTW)) { - expect(translation.trim(), source).not.toBe(""); - expect(placeholders(translation), source).toEqual(placeholders(source)); - expect(source, source).not.toMatch(/&(?:[a-z]+|#\d+);/i); + for (const catalog of [zhTW, ptBR]) { + for (const [source, translation] of Object.entries(catalog)) { + expect(translation.trim(), source).not.toBe(""); + expect(placeholders(translation), source).toEqual(placeholders(source)); + expect(source, source).not.toMatch(/&(?:[a-z]+|#\d+);/i); + } } }); + + it("keeps the Brazilian Portuguese catalog on exactly the same keys", () => { + expect(Object.keys(ptBR).sort()).toEqual(Object.keys(zhTW).sort()); + }); + + it("renders Brazilian Portuguese with interpolation and display labels", () => { + const { t, label } = createI18n("pt-BR"); + expect(t("Campaigns")).toBe("Campanhas"); + expect(t("Settings")).toBe("Configurações"); + expect(t("{count} connected accounts", { count: 2 })).toBe( + ptBR["{count} connected accounts"].replace("{count}", "2"), + ); + expect(t("{count} campaigns", { count: 0 })).toBe( + ptBR["{count} campaigns"].replace("{count}", "0"), + ); + expect(["SENT", "OWNER", "active", "CUSTOM_STATUS"].map(label)).toEqual([ + ptBR["Sent"], + ptBR["Owner"], + ptBR["Active"], + "CUSTOM_STATUS", + ]); + }); }); diff --git a/components/language-switcher.tsx b/components/language-switcher.tsx index a62bea1db..24c64ef99 100644 --- a/components/language-switcher.tsx +++ b/components/language-switcher.tsx @@ -36,6 +36,9 @@ export default function LanguageSwitcher() { + {failed && ( diff --git a/docs/localization.md b/docs/localization.md index 665d0295f..8eb309e20 100644 --- a/docs/localization.md +++ b/docs/localization.md @@ -1,6 +1,6 @@ # Interface languages -OpenReply defaults to English. Choose **English** or **繁體中文** in the dashboard +OpenReply defaults to English. Choose **English**, **繁體中文** or **Português (Brasil)** in the dashboard sidebar, under **Settings → Interface language**, or on the sign-in screen. The choice is stored in a browser cookie for one year and applies to the dashboard, sign-in screens, workspace invitations, and shared campaign reports. @@ -16,8 +16,10 @@ interface translation's scope. ## Adding or changing copy -- `lib/i18n/zh-TW.json` maps English source copy to Traditional Chinese. Use - complete sentences with named placeholders when word order can vary. +- `lib/i18n/zh-TW.json` maps English source copy to Traditional Chinese and + `lib/i18n/pt-BR.json` to Brazilian Portuguese. Both catalogs must carry exactly + the same keys; a missing key fails the typecheck. Use complete sentences with + named placeholders when word order can vary. - Client components use `useI18n()`; server components use `await getI18n()`. `t("Hello, {name}!", { name })` checks both the message key and required placeholders at compile time. Never pass user content as a translation key. @@ -48,7 +50,7 @@ preference validation, persistence, English fallback, interpolation, and status labels. For browser verification: 1. Start with no locale cookie: sign-in should display English. -2. Switch to Traditional Chinese, reload, and navigate to another localized +2. Switch to Traditional Chinese (and again to Brazilian Portuguese), reload, and navigate to another localized route: the selected language, labels, and dates should remain consistent. 3. In the campaign editor, change a message and toggle an option without saving. Switch languages: both draft values and the selected post should remain. diff --git a/lib/i18n/index.ts b/lib/i18n/index.ts index 77fcba3eb..14678cb99 100644 --- a/lib/i18n/index.ts +++ b/lib/i18n/index.ts @@ -1,11 +1,20 @@ +import ptBRCatalog from "./pt-BR.json"; import zhTW from "./zh-TW.json"; export const LOCALE_COOKIE = "openreply-locale"; -export type Locale = "en" | "zh-TW"; +export type Locale = "en" | "zh-TW" | "pt-BR"; export type MessageKey = keyof typeof zhTW; +// Every catalog must translate exactly the same English keys. `satisfies` makes +// a missing key a compile error instead of an `undefined` at render time. +const ptBR = ptBRCatalog satisfies Record; +const catalogs: Record, Record> = { + "zh-TW": zhTW, + "pt-BR": ptBR, +}; + export function isLocale(value: unknown): value is Locale { - return value === "en" || value === "zh-TW"; + return value === "en" || value === "zh-TW" || value === "pt-BR"; } export function resolveLocale(value: unknown): Locale { @@ -53,7 +62,7 @@ const labels: Record = { export function createI18n(locale: Locale) { function t(key: K, ...args: MessageArgs): string { - const message = locale === "zh-TW" ? zhTW[key] : key; + const message = locale === "en" ? key : catalogs[locale][key]; const values = args[0] as Record | undefined; return message.replace(/\{(\w+)\}/g, (placeholder, name: string) => values?.[name] === undefined ? placeholder : String(values[name]), diff --git a/lib/i18n/pt-BR.json b/lib/i18n/pt-BR.json new file mode 100644 index 000000000..e9a08e5ad --- /dev/null +++ b/lib/i18n/pt-BR.json @@ -0,0 +1,450 @@ +{ + " (capped at {count})": " (máximo de {count})", + "% CTR": "% CTR", + "(no text)": "(sem texto)", + "(optional)": "(opcional)", + "+ Add A Link": "+ Adicionar um link", + "+ Add A Second Link": "+ Adicionar um segundo link", + "+ Add another reply": "+ Adicionar outra resposta", + ". Keywords go in one cell, separated by commas. Use": ". As palavras-chave vão em uma única célula, separadas por vírgulas. Use", + ". Optional:": ". Opcional:", + "2 links": "2 links", + "A DM containing any of these words gets the same reply, no comment needed.": "Uma DM que contenha qualquer uma destas palavras recebe a mesma resposta, sem precisar comentar.", + "A point is added daily — the chart appears once there are at least two.": "Um ponto é adicionado por dia — o gráfico aparece quando houver pelo menos dois.", + "A sign-in link was sent to your email.": "Um link de acesso foi enviado para o seu e-mail.", + "A specific post or reel": "Uma publicação ou reel específico", + "API key saved securely.": "Chave de API salva com segurança.", + "Accept Workspace Invitation - OpenReply": "Aceitar convite do espaço de trabalho - OpenReply", + "Accept invitation": "Aceitar convite", + "Accepting...": "Aceitando...", + "Account": "Conta", + "Account already connected": "Conta já conectada", + "Accounts": "Contas", + "Active": "Ativa", + "Active Campaigns": "Campanhas ativas", + "Active campaign": "Campanha ativa", + "Add a comment for": "Adicionar um comentário para", + "Add at least one keyword, or switch to any word.": "Adicione pelo menos uma palavra-chave ou mude para qualquer palavra.", + "Add the DM with the link.": "Adicione a DM com o link.", + "Admin": "Administrador", + "After connecting Instagram, return here and select it for OpenReply. Keep Zernio automations off for these campaigns to avoid sending twice.": "Depois de conectar o Instagram, volte aqui e selecione a conta para o OpenReply. Mantenha as automações do Zernio desligadas para estas campanhas, para evitar envios duplicados.", + "All": "Todos", + "All accounts": "Todas as contas", + "All time": "Todo o período", + "All-time": "Todo o período", + "Already used": "Já em uso", + "Already used by \"{name}\"": "Já em uso por \"{name}\"", + "Also replies when someone DMs": "Também responde quando alguém envia uma DM", + "And then, they will get": "E então, a pessoa vai receber", + "And then, they will get a DM": "E então, a pessoa vai receber uma DM", + "And this comment has": "E este comentário tem", + "Any comment": "Qualquer comentário", + "Any post or reel": "Qualquer publicação ou reel", + "Applications close rly soon!!": "As inscrições fecham em breve!!", + "Ask your workspace owner or admin to configure Zernio.": "Peça ao proprietário ou a um administrador do espaço de trabalho para configurar o Zernio.", + "Back": "Voltar", + "Back to campaigns": "Voltar para campanhas", + "Back to conversations": "Voltar para conversas", + "Back to sign in": "Voltar para o login", + "Btw just wanted to say thanks for following me, I appreciate the support 🙌": "Aliás, só queria agradecer por você me seguir, valeu pelo apoio 🙌", + "Built with OpenReply, the Instagram comment-to-DM campaign OS.": "Feito com OpenReply, o sistema de campanhas comentário→DM para Instagram.", + "Button": "Botão", + "Button label": "Texto do botão", + "Button label (e.g. Open link)": "Texto do botão (ex.: Abrir link)", + "CTR": "CTR", + "Campaign": "Campanha", + "Campaign DM Failures And Skips": "Falhas e envios ignorados de DMs das campanhas", + "Campaign Setup": "Configuração da campanha", + "Campaign details": "Detalhes da campanha", + "Campaign name": "Nome da campanha", + "Campaign not found.": "Campanha não encontrada.", + "Campaign post": "Publicação da campanha", + "Campaign reel": "Reel da campanha", + "Campaigns": "Campanhas", + "Cancel": "Cancelar", + "Change": "Variação", + "Check your email": "Verifique seu e-mail", + "Check your email - OpenReply": "Verifique seu e-mail - OpenReply", + "Clicks": "Cliques", + "Clicks divided by sent replies.": "Cliques divididos pelas respostas enviadas.", + "Client campaign report": "Relatório de campanha para cliente", + "Clone it yourself": "Clone você mesmo", + "Close": "Fechar", + "Collecting follower history": "Coletando histórico de seguidores", + "Comment": "Comentário", + "Comment webhooks and private replies depend on this connection.": "Os webhooks de comentários e as respostas privadas dependem desta conexão.", + "Commenter": "Autor do comentário", + "Comments": "Comentários", + "Configuring…": "Configurando…", + "Connect": "Conectar", + "Connect Instagram": "Conectar Instagram", + "Connect Instagram without creating your own Meta developer app. Zernio is a paid service and an OpenReply sponsor. Your campaigns and hosting stay in OpenReply.": "Conecte o Instagram sem criar seu próprio app de desenvolvedor da Meta. O Zernio é um serviço pago e patrocinador do OpenReply. Suas campanhas e a hospedagem continuam no OpenReply.", + "Connect an Instagram account first.": "Conecte uma conta do Instagram primeiro.", + "Connect an Instagram professional account to launch campaigns.": "Conecte uma conta profissional do Instagram para lançar campanhas.", + "Connect another Instagram account": "Conectar outra conta do Instagram", + "Connect using your own Meta app": "Conectar usando seu próprio app da Meta", + "Connect your Instagram account first": "Conecte sua conta do Instagram primeiro", + "Connected": "Conectada", + "Connected via Zernio": "Conectada via Zernio", + "Conversations": "Conversas", + "Copied!": "Copiado!", + "Copy": "Copiar", + "Copy URL": "Copiar URL", + "Could not accept invitation": "Não foi possível aceitar o convite", + "Could not change language. Please try again.": "Não foi possível alterar o idioma. Tente novamente.", + "Could not invite member": "Não foi possível convidar o membro", + "Could not load connection.": "Não foi possível carregar a conexão.", + "Could not stage the import in this browser.": "Não foi possível preparar a importação neste navegador.", + "Could not update connection.": "Não foi possível atualizar a conexão.", + "Create Campaign": "Criar campanha", + "Create a profile in Zernio, then refresh this page.": "Crie um perfil no Zernio e depois atualize esta página.", + "Create your first comment-to-DM campaign to turn a post or reel into a measurable conversation flow.": "Crie sua primeira campanha comentário→DM para transformar uma publicação ou reel em um fluxo de conversa mensurável.", + "Created": "Criada em", + "DM Logs": "Logs de DMs", + "DM trigger": "Gatilho por DM", + "DMs Sent": "DMs enviadas", + "DMs sent": "DMs enviadas", + "DMs sent this month": "DMs enviadas este mês", + "DMs — Last 7 Days": "DMs — Últimos 7 dias", + "Dashboard": "Painel", + "Date": "Data", + "Dedup": "Duplicada", + "Delayed": "Atrasada", + "Delete": "Excluir", + "Delete this campaign? This cannot be undone.": "Excluir esta campanha? Esta ação não pode ser desfeita.", + "Deploy your own copy": "Implante sua própria cópia", + "Details unavailable": "Detalhes indisponíveis", + "Diagnostics": "Diagnóstico", + "Disconnect": "Desconectar", + "Disconnect Instagram? Campaigns for this account will stop sending DMs.": "Desconectar o Instagram? As campanhas desta conta vão parar de enviar DMs.", + "Disconnecting...": "Desconectando...", + "Dismiss demo notice": "Dispensar aviso de demonstração", + "Down": "Queda", + "Duplicate": "Duplicar", + "Duplicates, limits, or no-send outcomes.": "Duplicadas, limites ou resultados sem envio.", + "Easier Instagram setup": "Configuração mais fácil do Instagram", + "Edit": "Editar", + "Edit campaign": "Editar campanha", + "Email me a magic link": "Enviar um link mágico por e-mail", + "Enter a word or multiple": "Digite uma ou mais palavras", + "Every DM to this account gets the reply below — use with care.": "Toda DM enviada para esta conta recebe a resposta abaixo — use com cuidado.", + "Failed": "Falhou", + "Failed to load conversations": "Falha ao carregar as conversas", + "Failed to load overview": "Falha ao carregar a visão geral", + "Failed to load posts": "Falha ao carregar as publicações", + "Failed to save campaign": "Falha ao salvar a campanha", + "Failed to send message": "Falha ao enviar a mensagem", + "Fields are prefilled from your CSV. Pick the reel, edit anything, and save to load the next one — or Skip if you don’t want this one.": "Os campos foram preenchidos a partir do seu CSV. Escolha o reel, edite o que quiser e salve para carregar o próximo — ou clique em Pular se não quiser este.", + "Fill with a sample": "Preencher com um exemplo", + "Follow gate": "Trava de seguir", + "Follower count unavailable": "Número de seguidores indisponível", + "Followers": "Seguidores", + "Followers over time": "Seguidores ao longo do tempo", + "Fri": "Sex", + "Generated": "Gerado em", + "Get a Zernio API key": "Obter uma chave de API do Zernio", + "Go Live": "Ativar", + "Health, queues, webhook failures, billing events, and worker alerts.": "Saúde, filas, falhas de webhook, eventos de cobrança e alertas do worker.", + "Healthy": "Saudável", + "Hello, {name}!": "Olá, {name}!", + "Hey there! I'm so happy you're here 😊": "Oi! Que bom que você está aqui 😊", + "Import": "Importar", + "Import campaigns": "Importar campanhas", + "Importing {current} of {total}.": "Importando {current} de {total}.", + "Inbox": "Caixa de entrada", + "Insights": "Insights", + "Instagram Connection": "Conexão com o Instagram", + "Instagram accepted the login but the connection could not be completed. This is usually a mismatched redirect URI or an app that is missing the required permissions.": "O Instagram aceitou o login, mas não foi possível concluir a conexão. Normalmente isso acontece por uma URI de redirecionamento divergente ou por um app sem as permissões necessárias.", + "Instagram account": "Conta do Instagram", + "Instagram app not configured": "App do Instagram não configurado", + "Instagram connection cancelled": "Conexão com o Instagram cancelada", + "Instagram connection expired": "Conexão com o Instagram expirada", + "Instagram connection failed": "Falha na conexão com o Instagram", + "Instagram could not load the details of this conversation. Other conversations are still available. You can check this chat in Instagram.": "O Instagram não conseguiu carregar os detalhes desta conversa. As outras conversas continuam disponíveis. Você pode ver este chat no Instagram.", + "Instagram could not load this conversation.": "O Instagram não conseguiu carregar esta conversa.", + "Instagram post": "Publicação do Instagram", + "Instagram webhook": "Webhook do Instagram", + "Interface language": "Idioma da interface", + "Invite": "Convidar", + "Inviting...": "Convidando...", + "Join {workspace}": "Entrar em {workspace}", + "Keywords": "Palavras-chave", + "LIVE": "ATIVA", + "Language": "Idioma", + "Last 100": "Últimas 100", + "Last 25": "Últimas 25", + "Last 50": "Últimas 50", + "Last 7 Days": "Últimos 7 dias", + "Last heartbeat {seconds}s ago": "Último sinal há {seconds}s", + "Last send:": "Último envio:", + "Likes": "Curtidas", + "Link clicks": "Cliques no link", + "Loading…": "Carregando…", + "Login - OpenReply": "Entrar - OpenReply", + "Member": "Membro", + "Menu": "Menu", + "Message…": "Mensagem…", + "Mon": "Seg", + "More actions": "Mais ações", + "Needs attention": "Requer atenção", + "New Campaign": "Nova campanha", + "New campaign": "Nova campanha", + "Next": "Próxima", + "No DM failures or skips.": "Nenhuma falha ou envio ignorado de DM.", + "No activity yet": "Nenhuma atividade ainda", + "No campaigns match your search.": "Nenhuma campanha corresponde à sua busca.", + "No campaigns yet": "Nenhuma campanha ainda", + "No conversations yet.": "Nenhuma conversa ainda.", + "No failed webhook events.": "Nenhum evento de webhook com falha.", + "No heartbeat found": "Nenhum sinal encontrado", + "No image": "Sem imagem", + "No keyword matches yet": "Nenhuma palavra-chave correspondida ainda", + "No keywords": "Sem palavras-chave", + "No logs found": "Nenhum log encontrado", + "No match": "Sem correspondência", + "No matched keyword data yet.": "Ainda não há dados de palavras-chave correspondidas.", + "No messages.": "Nenhuma mensagem.", + "No operational events recorded.": "Nenhum evento operacional registrado.", + "No posts found": "Nenhuma publicação encontrada", + "No posts match “": "Nenhuma publicação corresponde a “", + "No sends yet": "Nenhum envio ainda", + "No snapshots recorded yet.": "Nenhum registro salvo ainda.", + "No token refresh failures.": "Nenhuma falha na renovação de token.", + "No worker alerts recorded.": "Nenhum alerta do worker registrado.", + "None": "Nenhum", + "Not attached": "Não vinculada", + "Not connected": "Não conectada", + "Not permitted": "Sem permissão", + "Now": "Agora", + "One day recorded so far.": "Um dia registrado até agora.", + "One is picked at random each time, so replies don't look identical.": "Uma é escolhida aleatoriamente a cada vez, para que as respostas não fiquem idênticas.", + "Only workspace owners and admins can connect an Instagram account.": "Somente proprietários e administradores do espaço de trabalho podem conectar uma conta do Instagram.", + "Open on Instagram": "Abrir no Instagram", + "OpenReply - Open source Instagram comment-to-DM automation": "OpenReply - Automação open source de comentário→DM para Instagram", + "Opening message": "Mensagem de abertura", + "Operational Event Timeline": "Linha do tempo de eventos operacionais", + "Optional connection provider": "Provedor de conexão opcional", + "Overview": "Visão geral", + "Owner": "Proprietário", + "PAUSED": "PAUSADA", + "Paste a CSV with a header row and at least one campaign.": "Cole um CSV com uma linha de cabeçalho e pelo menos uma campanha.", + "Paste a CSV with one row per campaign. Each row opens in the builder prefilled and editable, so you can review it and pick the reel before saving. Required columns are": "Cole um CSV com uma linha por campanha. Cada linha abre no editor já preenchida e editável, para você revisar e escolher o reel antes de salvar. As colunas obrigatórias são", + "Paused": "Pausada", + "Paused campaign": "Campanha pausada", + "Pending": "Pendente", + "Pending invites": "Convites pendentes", + "Pick a post or reel to trigger the campaign.": "Escolha uma publicação ou reel para acionar a campanha.", + "Plan limit": "Limite do plano", + "Play reel preview": "Reproduzir prévia do reel", + "Post": "Publicação", + "Posts": "Publicações", + "Powered by OpenReply": "Desenvolvido com OpenReply", + "Preview": "Prévia", + "Previous": "Anterior", + "Private replies successfully sent.": "Respostas privadas enviadas com sucesso.", + "Production Diagnostics": "Diagnóstico de produção", + "Public reply under the post": "Resposta pública na publicação", + "Queue": "Fila", + "Range": "Período", + "Rate limited": "Limite de envio atingido", + "Reach": "Alcance", + "Read the setup guide": "Ler o guia de configuração", + "Read-only Instagram comment-to-DM campaign report for {name}.": "Relatório somente leitura da campanha comentário→DM do Instagram de {name}.", + "Recent": "Recentes", + "Recent Activity": "Atividade recente", + "Recent Worker Alerts": "Alertas recentes do worker", + "Reconnect Instagram": "Reconectar Instagram", + "Reconnect your account to grant it — likes and comments are shown in the meantime.": "Reconecte sua conta para conceder a permissão — enquanto isso, curtidas e comentários continuam visíveis.", + "Refresh": "Atualizar", + "Remove": "Remover", + "Remove Zernio connection": "Remover conexão com o Zernio", + "Remove reply": "Remover resposta", + "Remove the Zernio key and this OpenReply webhook? Disconnect its Instagram accounts from OpenReply first.": "Remover a chave do Zernio e este webhook do OpenReply? Desconecte antes as contas do Instagram vinculadas a ele no OpenReply.", + "Remove {keyword}": "Remover {keyword}", + "Repair webhook connection": "Reparar conexão do webhook", + "Replies that need operational review.": "Respostas que precisam de revisão operacional.", + "Reply": "Responder", + "Report Not Found": "Relatório não encontrado", + "Resume": "Retomar", + "Review and import": "Revisar e importar", + "Revoke": "Revogar", + "Row {row} is missing keywords or a message.": "A linha {row} está sem palavras-chave ou sem mensagem.", + "Sat": "Sáb", + "Save API key": "Salvar chave de API", + "Save changes": "Salvar alterações", + "Save profile and configure webhook": "Salvar perfil e configurar webhook", + "Saved": "Salvamentos", + "Saved in this browser. Campaign messages stay unchanged.": "Salvo neste navegador. As mensagens das campanhas não mudam.", + "Saving…": "Salvando…", + "Search campaigns by name, keyword, or message…": "Buscar campanhas por nome, palavra-chave ou mensagem…", + "Search your posts by caption…": "Buscar publicações pela legenda…", + "Second button label": "Texto do segundo botão", + "See": "Consulte", + "See activity": "Ver atividade", + "Select a conversation to read and reply.": "Selecione uma conversa para ler e responder.", + "Select a profile": "Selecione um perfil", + "Selected": "Selecionada", + "Self-hosted": "Auto-hospedado", + "Self-hosted — no plan limits.": "Auto-hospedado — sem limites de plano.", + "Send": "Enviar", + "Send it": "Enviar", + "Send me the link": "Me envie o link", + "Sending…": "Enviando…", + "Sends": "Envios", + "Sent": "Enviada", + "Sent replies": "Respostas enviadas", + "Sent replies and tracked clicks by day.": "Respostas enviadas e cliques rastreados por dia.", + "Sent right after the link.": "Enviada logo após o link.", + "Sent right after they tap through.": "Enviada logo após a pessoa tocar no botão.", + "Sent you a DM! 📩": "Te enviei uma DM! 📩", + "Sent {minutes} min after the link.": "Enviada {minutes} min após o link.", + "Sent {minutes} min after they tap through.": "Enviada {minutes} min após a pessoa tocar no botão.", + "Set": "Defina", + "Settings": "Configurações", + "Shares": "Compartilhamentos", + "Show": "Mostrar", + "Show chart": "Mostrar gráfico", + "Show table": "Mostrar tabela", + "Showing {start}–{end} of {total}": "Mostrando {start}–{end} de {total}", + "Sign in by email, then connect your Instagram professional account.": "Entre por e-mail e depois conecte sua conta profissional do Instagram.", + "Sign in to accept": "Entre para aceitar", + "Sign in to manage Instagram comment-to-DM campaigns.": "Entre para gerenciar campanhas comentário→DM do Instagram.", + "Sign in to use the {name} template.": "Entre para usar o modelo {name}.", + "Sign-in is off on this demo": "O login está desativado nesta demonstração", + "Signing in here will not send DMs for your Instagram account. OpenReply is self-hosted, so it only works on a deployment you run yourself, with your own Meta app and your own domain.": "Entrar aqui não enviará DMs pela sua conta do Instagram. O OpenReply é auto-hospedado, então só funciona em uma instalação que você mesmo executa, com seu próprio app da Meta e seu próprio domínio.", + "Skip": "Pular", + "Skip & finish": "Pular e concluir", + "Skipped": "Ignorada", + "Source post": "Publicação de origem", + "Status": "Status", + "Stop": "Pausar", + "Sun": "Dom", + "Supported by": "Apoio de", + "Team": "Equipe", + "Template selected": "Modelo selecionado", + "That Instagram account is connected to another workspace. Disconnect it there first, or connect a different account.": "Essa conta do Instagram está conectada a outro espaço de trabalho. Desconecte-a lá primeiro ou conecte outra conta.", + "The exact link sent": "O link exato enviado", + "The login link was missing or older than 10 minutes. Click Connect Instagram to start a fresh attempt.": "O link de login estava ausente ou tinha mais de 10 minutos. Clique em Conectar Instagram para tentar novamente.", + "Then a follow-up message": "Depois, uma mensagem de acompanhamento", + "They must follow first": "A pessoa precisa seguir primeiro", + "They will get": "A pessoa vai receber", + "They will get an opening DM": "A pessoa vai receber uma DM de abertura", + "This campaign does not have a tracked link.": "Esta campanha não tem um link rastreado.", + "This invitation has expired. Ask the workspace owner to resend it.": "Este convite expirou. Peça ao proprietário do espaço de trabalho para reenviá-lo.", + "This is the public demo — it doesn’t create real accounts or send DMs. To use OpenReply for real, clone it and run your own instance with your own Meta app and domain.": "Esta é a demonstração pública — ela não cria contas reais nem envia DMs. Para usar o OpenReply de verdade, clone o projeto e rode sua própria instância com seu próprio app da Meta e domínio.", + "Thu": "Qui", + "Time": "Horário", + "Toggle sidebar": "Alternar barra lateral", + "Token Refresh Failures": "Falhas na renovação de token", + "Token expires": "Token expira em", + "Top Keywords": "Principais palavras-chave", + "Tracked Links": "Links rastreados", + "Tracked link visits from replies.": "Visitas aos links rastreados vindas das respostas.", + "Tue": "Ter", + "Type keyword and press Enter...": "Digite a palavra-chave e pressione Enter...", + "Unknown error": "Erro desconhecido", + "Unknown member": "Membro desconhecido", + "Untitled campaign": "Campanha sem título", + "Usage": "Uso", + "Use an unrestricted, read-write key with Inbox access. OpenReply registers a webhook for this workspace. The key is encrypted and never shown again.": "Use uma chave sem restrições, de leitura e escrita, com acesso à caixa de entrada. O OpenReply registra um webhook para este espaço de trabalho. A chave é criptografada e nunca mais será exibida.", + "Use commas to separate words": "Use vírgulas para separar as palavras", + "Use in OpenReply": "Usar no OpenReply", + "Use the magic link account for": "Use a conta do link mágico de", + "View Instagram post": "Ver publicação no Instagram", + "View all comments": "Ver todos os comentários", + "View pricing": "Ver preços", + "Views": "Visualizações", + "Views, reach, saved and shares need the insights permission.": "Visualizações, alcance, salvamentos e compartilhamentos exigem a permissão de insights.", + "Waiting": "Aguardando", + "Waiting for next reel": "Aguardando o próximo reel", + "We send the link only after they tap the button and Instagram confirms the follow. If it can't be verified, we send it anyway.": "Só enviamos o link depois que a pessoa toca no botão e o Instagram confirma que ela está seguindo. Se não for possível verificar, enviamos mesmo assim.", + "We sent you a secure sign-in link. Open it on this device to continue.": "Enviamos um link de acesso seguro. Abra-o neste dispositivo para continuar.", + "Webhook Failures": "Falhas de webhook", + "Webhook configured. Choose an Instagram account for OpenReply:": "Webhook configurado. Escolha uma conta do Instagram para o OpenReply:", + "Webhook pending": "Webhook pendente", + "Webhook ready": "Webhook pronto", + "Wed": "Qua", + "When someone comments on": "Quando alguém comentar em", + "Work email": "E-mail de trabalho", + "Worker health": "Saúde do worker", + "Workspace": "Espaço de trabalho", + "Workspace invitation": "Convite para o espaço de trabalho", + "Write a message": "Escreva uma mensagem", + "Write a reply… (Enter to send, Shift+Enter for a new line)": "Escreva uma resposta… (Enter para enviar, Shift+Enter para nova linha)", + "You declined the permission prompt on Instagram. Start again and accept all requested permissions.": "Você recusou a solicitação de permissão no Instagram. Comece novamente e aceite todas as permissões solicitadas.", + "You were invited as {role} for {email}.": "Você foi convidado como {role} para {email}.", + "You: ": "Você: ", + "Your next reel": "Seu próximo reel", + "Your opening DM needs a message and a button label.": "Sua DM de abertura precisa de uma mensagem e de um texto de botão.", + "Your opening message…": "Sua mensagem de abertura…", + "Zernio API key": "Chave de API do Zernio", + "Zernio profile": "Perfil do Zernio", + "Zernio, OpenReply sponsor": "Zernio, patrocinador do OpenReply", + "a DM with a link": "uma DM com um link", + "a follow requirement first": "primeiro, a exigência de seguir", + "a follow-up thank-you message": "uma mensagem de agradecimento em seguida", + "a specific post or reel": "uma publicação ou reel específico", + "a specific word or words": "uma ou mais palavras específicas", + "also reply when someone DMs": "também responder quando alguém enviar uma DM", + "an opening DM": "uma DM de abertura", + "and": "e", + "and restart the server:": "e reinicie o servidor:", + "any post or reel": "qualquer publicação ou reel", + "any word": "qualquer palavra", + "anything": "qualquer coisa", + "clicks": "cliques", + "e.g. YC referral": "ex.: indicação YC", + "failed": "falharam", + "followers": "seguidores", + "for how to obtain each value. Note that": "para saber como obter cada valor. Observe que", + "from @": "de @", + "i'm following": "já estou seguindo", + "in the message to insert the tracked link.": "na mensagem para inserir o link rastreado.", + "inserts the tracked link;": "insere o link rastreado;", + "is a demo instance.": "é uma instância de demonstração.", + "is a demo. OpenReply is self-hosted — signing in here will not send DMs for your account.": "é uma demonstração. O OpenReply é auto-hospedado — entrar aqui não enviará DMs pela sua conta.", + "keywords · Press Enter or comma to add": "palavras-chave · Pressione Enter ou vírgula para adicionar", + "min later": "min depois", + "minutes after the link": "minutos após o link", + "more": "mais", + "must be a 64-character hex string.": "deve ser uma string hexadecimal de 64 caracteres.", + "next post or reel": "próxima publicação ou reel", + "nice!": "legal!", + "not available": "não disponível", + "over {count} days": "em {count} dias", + "personalizes it. Max 24 hours, to stay inside Instagram's messaging window.": "personaliza a mensagem. Máximo de 24 horas, para ficar dentro da janela de mensagens do Instagram.", + "personalizes.": "personaliza.", + "quick favor before i send your link. i don't make any money from this, it's free. if you want to support me, just don't unfollow after, and star the repo on github if it helps you. tap the button once you're following and i'll send it over": "um favorzinho antes de eu te mandar o link. não ganho nada com isso, é de graça. se quiser me apoiar, é só não deixar de seguir depois e dar uma estrela no repositório no github se ele te ajudar. toque no botão quando estiver seguindo que eu te mando", + "redirects to": "redireciona para", + "reply to their comments under the post": "responder aos comentários na publicação", + "runs": "execuções", + "sent": "enviadas", + "skipped": "ignoradas", + "that day": "naquele dia", + "the required environment variables": "as variáveis de ambiente obrigatórias", + "their message": "a mensagem da pessoa", + "there": "você", + "these environment variables": "estas variáveis de ambiente", + "these words": "estas palavras", + "your link": "seu link", + "{count} accounts": "{count} contas", + "{count} campaign": "{count} campanha", + "{count} campaigns": "{count} campanhas", + "{count} clicks": "{count} cliques", + "{count} connected": "{count} conectadas", + "{count} connected Instagram profile": "{count} perfil do Instagram conectado", + "{count} connected Instagram profiles": "{count} perfis do Instagram conectados", + "{count} connected account": "{count} conta conectada", + "{count} connected accounts": "{count} contas conectadas", + "{count} contact": "{count} contato", + "{count} contacts": "{count} contatos", + "{count} now": "{count} agora", + "{count} of {total} campaigns": "{count} de {total} campanhas", + "{count} post": "{count} publicação", + "{count} posts": "{count} publicações", + "{count} sent": "{count} enviadas", + "{name} Campaign Report": "Relatório da campanha {name}", + "{type} post": "Publicação {type}", + "”": "”", + "← Campaigns": "← Campanhas" +}