This is the full developer documentation for Gaio # Gaio social > Tudo num só lugar. # Introdução > Visão geral do ecossistema da Gaio e como começar * [**Backend**](/backend/overview) — API principal e serviços de domínio * [**Frontend**](/frontend/overview) — Interface web do produto ## LLMs [Seção intitulada “LLMs”](#llms) Esta documentação está disponível em formato otimizado para LLMs: * [`llms.txt`](/llms.txt) — Índice com links para todas as páginas * [`llms-small.txt`](/llms-small.txt) — Índice reduzido com apenas as seções principais * [`llms-full.txt`](/llms-full.txt) — Conteúdo completo de toda a documentação em um único arquivo ## Próximos passos [Seção intitulada “Próximos passos”](#próximos-passos) Explore a documentação de cada módulo na barra lateral. # Internacionalização (i18n) > Sistema de internacionalização do backend Documentação do sistema de internacionalização para uso interno no backend. *** ## Índice [Seção intitulada “Índice”](#índice) * [Visão Geral](#vis%C3%A3o-geral) * [Estrutura de Dados](#estrutura-de-dados) * [Preferências do Usuário](#prefer%C3%AAncias-do-usu%C3%A1rio) * [API Endpoints](#api-endpoints) * [Repository Layer](#repository-layer) * [Uso no Código](#uso-no-c%C3%B3digo) * [Checklist para Novas Features](#checklist-para-novas-features) *** ## Visão Geral [Seção intitulada “Visão Geral”](#visão-geral) O sistema de i18n permite que cada usuário escolha seu idioma preferido para a aplicação. ### Características Principais [Seção intitulada “Características Principais”](#características-principais) | Aspecto | Valor | | ----------------- | ----------------------------------- | | **Idiomas** | English (en-US), Portuguese (pt-BR) | | **Idioma Padrão** | Portuguese (pt-BR) | | **Armazenamento** | Tabela `users`, coluna `lang` | | **Escopo** | Por usuário | | **Autorização** | `publicUsers` (usuário autenticado) | *** ## Estrutura de Dados [Seção intitulada “Estrutura de Dados”](#estrutura-de-dados) ### Language [Seção intitulada “Language”](#language) Localizado em: `packages/common/src/i18n/types.ts` ```typescript export enum Language { English = "en-US", Portuguese = "pt-BR", } export const AVAILABLE_LANGUAGES = Object.values(Language); ``` ### Valores Disponíveis [Seção intitulada “Valores Disponíveis”](#valores-disponíveis) | Enum Value | String Value | Descrição | | ------------ | ------------ | --------- | | `English` | `en-US` | Inglês | | `Portuguese` | `pt-BR` | Português | ### Exportação [Seção intitulada “Exportação”](#exportação) O tipo `Language` e a constante `AVAILABLE_LANGUAGES` são exportados via `@gaio/common`: packages/common/src/export.ts ```typescript export * from "./i18n/types"; ``` *** ## Preferências do Usuário [Seção intitulada “Preferências do Usuário”](#preferências-do-usuário) ### Schema do Usuário [Seção intitulada “Schema do Usuário”](#schema-do-usuário) Localizado em: `packages/console/src/users/schemas/user.ts` ```typescript import type { Language } from "@gaio/common"; export interface UserSchema extends Database.Schema { // ... outros campos ... /** * User preferred language. */ lang?: Enum.Default; // ... outros campos ... } ``` ### Detalhes do Campo [Seção intitulada “Detalhes do Campo”](#detalhes-do-campo) | Propriedade | Valor | | ----------- | --------------------------------------------- | | Nome | `lang` | | Tipo | `Enum.Default` | | Obrigatório | Não (opcional) | | Default | `Language.Portuguese` (`pt-BR`) | | Tabela | `users` | *** ## API Endpoints [Seção intitulada “API Endpoints”](#api-endpoints) ### GET /api/console/user-preferences [Seção intitulada “GET /api/console/user-preferences”](#get-apiconsoleuser-preferences) Retorna as preferências do usuário autenticado. **Authorizer:** `publicUsers` **Response:** ```typescript declare class UserPreferencesResponse implements Http.Response { status: 200; body: { lang?: Language | null; }; } ``` **Exemplo de Response:** ```json { "lang": "pt-BR" } ``` *** ### PATCH /api/console/update-user-preferences [Seção intitulada “PATCH /api/console/update-user-preferences”](#patch-apiconsoleupdate-user-preferences) Atualiza as preferências do usuário autenticado. **Authorizer:** `publicUsers` **Request Body:** ```typescript { lang?: Language; } ``` **Exemplo de Request:** ```json { "lang": "en-US" } ``` **Response:** `204 No Content` *** ### Definição das Rotas [Seção intitulada “Definição das Rotas”](#definição-das-rotas) Localizado em: `packages/console/src/users/routes.ts` ```typescript export type UserRoutes = [ // ... outras rotas ... { path: "GET /api/console/user-preferences"; authorizer: typeof publicUsers; handler: typeof userPreferencesHandler; }, { path: "PATCH /api/console/update-user-preferences"; authorizer: typeof publicUsers; handler: typeof updateUserPreferencesHandler; }, ]; ``` *** ## Repository Layer [Seção intitulada “Repository Layer”](#repository-layer) Localizado em: `packages/console/src/users/repository.ts` ### UserRepository.preferences [Seção intitulada “UserRepository.preferences”](#userrepositorypreferences) Lê a preferência de idioma do usuário. ```typescript export const preferences = async (client: DbClient, userId: string) => { const user = await client.users.findOne({ select: { lang: true, }, where: { deleted_at: null, id: userId, }, }); return { lang: user?.lang ?? null, }; }; ``` *** ### UserRepository.updatePreferences [Seção intitulada “UserRepository.updatePreferences”](#userrepositoryupdatepreferences) Atualiza a preferência de idioma do usuário. ```typescript type UpdatePreferencesInput = { lang?: Language; }; export const updatePreferences = async ( client: DbClient, id: string, input: UpdatePreferencesInput, ) => { const now = new Date().toISOString(); const response = await client.users.updateOne({ select: { id: true, }, data: { lang: input.lang, updated_at: now, }, where: { deleted_at: null, id, }, }); return response?.id; }; ``` *** ## Uso no Código [Seção intitulada “Uso no Código”](#uso-no-código) ### Import do Tipo Language [Seção intitulada “Import do Tipo Language”](#import-do-tipo-language) ```typescript import { Language, AVAILABLE_LANGUAGES } from "@gaio/common"; // Verificar se um valor é um idioma válido const isValidLanguage = (value: string): value is Language => { return AVAILABLE_LANGUAGES.includes(value as Language); }; // Usar em validações if (!isValidLanguage(userInput)) { throw new HttpBadRequestError("Invalid language."); } ``` ### Acessar Preferência do Usuário [Seção intitulada “Acessar Preferência do Usuário”](#acessar-preferência-do-usuário) ```typescript import { UserRepository } from "../repository.js"; // Em um endpoint export async function someHandler( request: SomeRequest, context: Service.Context, ): Promise { const { userId } = request.identity; const { consoleDb } = context; // Obter preferência de idioma const { lang } = await UserRepository.preferences(consoleDb, userId); // Usar o idioma (default pt-BR se null) const userLanguage = lang ?? Language.Portuguese; // ... usar userLanguage para formatação, mensagens, etc. } ``` *** ## Checklist para Novas Features [Seção intitulada “Checklist para Novas Features”](#checklist-para-novas-features) * [ ] Verificar se a feature precisa exibir conteúdo localizado * [ ] Usar o enum `Language` para campos de idioma (nunca strings literais) * [ ] Respeitar a preferência do usuário (`UserRepository.preferences`) * [ ] Usar `Language.Portuguese` como fallback quando `lang` for `null` * [ ] Testar com ambos os idiomas (en-US e pt-BR) # ManyChat Importer > Importador de workflows do ManyChat ## Resumo [Seção intitulada “Resumo”](#resumo) O `ManyChatWorkflowImporter` converte um flow do ManyChat em nós internos do workflow (actions) e em gatilhos (triggers). Ele: * Mescla `contents` e `draft_batch.contents`, filtra itens removidos e resolve o root. * Mapeia mensagens, ações, condições e nós simples (`goto`, `smart_delay`, `note`, `split`). * Normaliza IDs dos nós (ex.: `n1`, `n2`) e reconecta `success_target`. * Coleta `tags`, `customFields`, `uploads` e `warnings`. * Extrai `keywords`, `triggerType`, `mediaTypes`, `postSelected` e `repliesListMessages`. Arquivo principal: `packages/common/src/services/workflow/importers/manychat/importer.ts`. *** ## Como testar via CLI (`npm run tool`) [Seção intitulada “Como testar via CLI (npm run tool)”](#como-testar-via-cli-npm-run-tool) 1. Edite o input em `packages/common/src/tools/commands/manychat/input.json` com: * `flow`: JSON do ManyChat. * `extra_data`: `custom_fields` e `tags` (note o snake\_case aqui). 2. Rode o comando na raiz do repo: ```bash npm run tool manychat:import ``` 3. Confira o output em `packages/common/src/tools/commands/manychat/output.json`. * `workflow` existe quando a conversão passa. * Se falhar, verifique `error` e `warnings`. *** ## Como testar via JSONs dos testes [Seção intitulada “Como testar via JSONs dos testes”](#como-testar-via-jsons-dos-testes) Fixtures ficam em: * Inputs: `packages/common/test/workflow-importer/manychat/input` * Outputs: `packages/common/test/workflow-importer/manychat/output` * Extra data: `packages/common/test/workflow-importer/manychat/input/extra-data.json` Uso via `ManyChatTestFactory` em: `packages/common/test/workflow-importer/manychat/specs/*.spec.ts`. Boilerplate comum: * `factory.testAction('add_tag')` * `factory.testMessage('text_message')` * `factory.testNode('smart_delay')` * `factory.testTrigger('instagram')` * `factory.testFullImport('flow_full')` * `factory.testValidation('flow_invalid', false)` Rodar testes do pacote: ```bash npm run test -w @gaio/common ``` *** ## Melhores práticas [Seção intitulada “Melhores práticas”](#melhores-práticas) * Mantenha fixtures pequenas e focadas; deixe flows completos para o CLI. * Atualize `extra-data.json` quando adicionar tags/campos usados nos testes. * Cubra caminho feliz e warnings (ex.: `unsupported_node`, `unresolved_entity`). * Verifique encadeamento `next` e normalização de IDs nos outputs esperados. * Para uploads, use URLs HTTP/HTTPS válidas para acionar `uploads`. * Se alterar triggers, adicione casos com `keywords` e `widgets`. # Notificações N8N > Eventos de notificação e integração com N8N webhooks ## Introdução [Seção intitulada “Introdução”](#introdução) Este documento descreve todos os eventos de notificação disponíveis no sistema backend do Gaio. Esses eventos são acionados automaticamente quando ações específicas ocorrem na plataforma e são enviados para o N8N para processamento e automação de marketing, CS e analytics. ## Estrutura do Evento [Seção intitulada “Estrutura do Evento”](#estrutura-do-evento) Todos os eventos de notificação compartilham uma estrutura base comum e incluem um payload específico do evento. ### Propriedades Base do Evento [Seção intitulada “Propriedades Base do Evento”](#propriedades-base-do-evento) Todo evento de notificação inclui estes campos padrão: ```json { "name": "event_type_name", "request_id": "uuid-v4-format", "idempotence_key": "unique-string-key", "created_at": "2024-01-28T10:30:00.000Z", "payload": { // Dados específicos do evento } } ``` | Campo | Tipo | Descrição | | ----------------- | ------ | ------------------------------------------------- | | `name` | string | Tipo de evento (veja tipos de eventos abaixo) | | `request_id` | string | Identificador UUID v4 para a solicitação | | `idempotence_key` | string | Chave única para prevenir processamento duplicado | | `created_at` | string | Timestamp ISO 8601 quando o evento foi criado | | `payload` | object | Dados específicos do evento (varia por tipo) | *** ## Eventos Disponíveis [Seção intitulada “Eventos Disponíveis”](#eventos-disponíveis) | Nome do Evento | Condição de Acionamento | | -------------------------------------------------------------- | ------------------------------------------- | | [`user_data_filled`](#dados-do-usuario-preenchidos) | Usuário preenche telefone pela primeira vez | | [`account_connected`](#conta-conectada) | Conta conecta com sucesso a serviço externo | | [`account_disconnected`](#conta-desconectada) | Conta desconecta do serviço externo | | [`subscription_plan_changed`](#plano-de-assinatura-alterado) | Plano/tier de assinatura muda | | [`subscription_usage_threshold`](#limite-de-uso-da-assinatura) | Uso atinge 50%, 75%, 90% ou 100% de limite | | [`subscription_unpaid_limit`](#limite-de-uso-n%C3%A3o-pago) | Uso em 100% E pagamento vencido | ### Dados do Usuário Preenchidos [Seção intitulada “Dados do Usuário Preenchidos”](#dados-do-usuário-preenchidos) **Nome do Evento:** `user_data_filled` **Quando é Acionado:** * Quando um usuário preenche seu número de telefone de contato pela primeira vez * O usuário deve passar de ter um telefone vazio para ter um número de telefone **Payload:** | Campo | Tipo | Obrigatório | Descrição | | -------------- | ------ | ----------- | ------------------------------------- | | `userId` | string | Sim | Identificador único do usuário (UUID) | | `accountId` | string | Sim | Identificador único da conta (UUID) | | `name` | string | Não | Nome completo do usuário | | `contactEmail` | string | Não | Email de contato do usuário | | `contactPhone` | string | Sim | Número de telefone (recém definido) | **Exemplo:** ```json { "name": "user_data_filled", "request_id": "550e8400-e29b-41d4-a716-446655440000", "idempotence_key": "user_550e8400_data_filled_1706438400", "created_at": "2024-01-28T10:30:00.000Z", "payload": { "userId": "550e8400-e29b-41d4-a716-446655440001", "accountId": "550e8400-e29b-41d4-a716-446655440002", "name": "João Silva", "contactEmail": "joao@exemplo.com", "contactPhone": "+5511999999999" } } ``` *** ### Conta Conectada [Seção intitulada “Conta Conectada”](#conta-conectada) **Nome do Evento:** `account_connected` **Quando é Acionado:** * Quando uma conta se conecta com sucesso a um serviço externo (Instagram, WhatsApp, etc.) * Status da conta muda para “Conectada” **Payload:** | Campo | Tipo | Obrigatório | Descrição | | ---------------- | ------ | ----------- | --------------------------------------------- | | `userId` | string | Sim | Identificador único do usuário (UUID) | | `accountId` | string | Sim | Identificador único da conta (UUID) | | `externalId` | string | Sim | ID da conta no serviço externo | | `connectionType` | string | Sim | Tipo de conexão (ex: “instagram”, “whatsapp”) | | `name` | string | Não | Nome completo do usuário | | `alias` | string | Não | Alias/nome de exibição da conta | | `contactPhone` | string | Não | Número de telefone de contato do usuário | | `contactEmail` | string | Não | Email de contato do usuário | **Exemplo:** ```json { "name": "account_connected", "request_id": "550e8400-e29b-41d4-a716-446655440003", "idempotence_key": "account_550e8400_connected_1706438500", "created_at": "2024-01-28T10:31:40.000Z", "payload": { "userId": "550e8400-e29b-41d4-a716-446655440001", "accountId": "550e8400-e29b-41d4-a716-446655440002", "externalId": "17841458234567890", "connectionType": "instagram", "name": "João Silva", "alias": "@joaosilva_oficial", "contactPhone": "+5511999999999", "contactEmail": "joao@exemplo.com" } } ``` *** ### Conta Desconectada [Seção intitulada “Conta Desconectada”](#conta-desconectada) **Nome do Evento:** `account_disconnected` **Quando é Acionado:** * Quando uma conta é desconectada de um serviço externo * Status da conta muda para “Desconectada” * Pode ocorrer por desconexão manual, expiração de token ou erros de API **Payload:** | Campo | Tipo | Obrigatório | Descrição | | ---------------- | ------ | ----------- | --------------------------------------------- | | `userId` | string | Sim | Identificador único do usuário (UUID) | | `accountId` | string | Sim | Identificador único da conta (UUID) | | `externalId` | string | Sim | ID da conta no serviço externo | | `connectionType` | string | Sim | Tipo de conexão (ex: “instagram”, “whatsapp”) | | `name` | string | Não | Nome completo do usuário | | `alias` | string | Não | Alias/nome de exibição da conta | | `contactEmail` | string | Não | Email de contato do usuário | | `contactPhone` | string | Não | Número de telefone de contato do usuário | **Exemplo:** ```json { "name": "account_disconnected", "request_id": "550e8400-e29b-41d4-a716-446655440004", "idempotence_key": "account_550e8400_disconnected_1706438600", "created_at": "2024-01-28T10:33:20.000Z", "payload": { "userId": "550e8400-e29b-41d4-a716-446655440001", "accountId": "550e8400-e29b-41d4-a716-446655440002", "externalId": "17841458234567890", "connectionType": "instagram", "name": "João Silva", "alias": "@joaosilva_oficial", "contactEmail": "joao@exemplo.com", "contactPhone": "+5511999999999" } } ``` *** ### Plano de Assinatura Alterado [Seção intitulada “Plano de Assinatura Alterado”](#plano-de-assinatura-alterado) **Nome do Evento:** `subscription_plan_changed` **Quando é Acionado:** * Quando um plano de assinatura ou tier é alterado * Pode ser acionado por upgrade, downgrade ou mudança de tier **Payload:** | Campo | Tipo | Obrigatório | Descrição | | ------------------- | ------- | ----------- | ---------------------------------------------------------- | | `userId` | string | Sim | Identificador único do usuário (UUID) | | `accountId` | string | Sim | Identificador único da conta (UUID) | | `name` | string | Não | Nome completo do usuário | | `contactEmail` | string | Não | Email de contato do usuário | | `contactPhone` | string | Não | Número de telefone de contato do usuário | | `changeType` | string | Sim | Tipo de mudança: “upgrade”, “downgrade”, “tier\_change” | | `previousPlanId` | string | Sim | Identificador único do plano anterior | | `previousPlanTitle` | string | Sim | Nome de exibição do plano anterior | | `newPlanId` | string | Sim | Identificador único do novo plano | | `newPlanTitle` | string | Sim | Nome de exibição do novo plano | | `previousLimit` | number | Não | Limite anterior (se aplicável) | | `newLimit` | number | Não | Novo limite de uso (se aplicável) | | `hasPaymentMethod` | boolean | Sim | Se a conta possui método de pagamento registrado no Stripe | **Tipos de Mudança:** * `upgrade`: Usuário passou para um plano de tier superior * `downgrade`: Usuário passou para um plano de tier inferior * `tier_change`: Usuário mudou de tiers no mesmo nível de plano **Exemplo:** ```json { "name": "subscription_plan_changed", "request_id": "550e8400-e29b-41d4-a716-446655440005", "idempotence_key": "subscription_550e8400_plan_changed_1706438700", "created_at": "2024-01-28T10:35:00.000Z", "payload": { "userId": "550e8400-e29b-41d4-a716-446655440001", "accountId": "550e8400-e29b-41d4-a716-446655440002", "name": "João Silva", "contactEmail": "joao@exemplo.com", "contactPhone": "+5511999999999", "changeType": "upgrade", "previousPlanId": "plan_basic_001", "previousPlanTitle": "Plano Básico", "newPlanId": "plan_pro_001", "newPlanTitle": "Plano Pro", "previousLimit": 1000, "newLimit": 5000, "hasPaymentMethod": true } } ``` *** ### Limite de Uso da Assinatura [Seção intitulada “Limite de Uso da Assinatura”](#limite-de-uso-da-assinatura) **Nome do Evento:** `subscription_usage_threshold` **Quando é Acionado:** * Quando o uso atinge 50%, 75%, 90% ou 100% do limite do último tier * Ajuda a avisar usuários antes de atingirem seus limites de uso * Só dispara uma vez por nível de limite por período de faturamento **Payload:** | Campo | Tipo | Obrigatório | Descrição | | ------------------ | ------- | ----------- | ---------------------------------------------------------- | | `userId` | string | Sim | Identificador único do usuário (UUID) | | `accountId` | string | Sim | Identificador único da conta (UUID) | | `name` | string | Não | Nome completo do usuário | | `contactEmail` | string | Não | Email de contato do usuário | | `contactPhone` | string | Não | Número de telefone de contato do usuário | | `planId` | string | Sim | Identificador único do plano atual | | `planTitle` | string | Sim | Nome de exibição do plano atual | | `thresholdLevel` | number | Sim | Limite atingido: `50`, `75`, `90` ou `100` | | `currentUsage` | number | Sim | Contagem de uso atual | | `usageLimit` | number | Sim | Limite máximo de uso do tier atual | | `usagePercentage` | number | Sim | Percentual do limite usado (decimal: 0.50 = 50%) | | `hasPaymentMethod` | boolean | Sim | Se a conta possui método de pagamento registrado no Stripe | **Níveis de Limite:** * `50`: Usuário usou 50% de seu limite * `75`: Usuário usou 75% de seu limite * `90`: Usuário usou 90% de seu limite * `100`: Usuário atingiu 100% de seu limite **Exemplo:** ```json { "name": "subscription_usage_threshold", "request_id": "550e8400-e29b-41d4-a716-446655440006", "idempotence_key": "subscription_550e8400_threshold_75_1706438800", "created_at": "2024-01-28T10:36:40.000Z", "payload": { "userId": "550e8400-e29b-41d4-a716-446655440001", "accountId": "550e8400-e29b-41d4-a716-446655440002", "name": "João Silva", "contactEmail": "joao@exemplo.com", "contactPhone": "+5511999999999", "planId": "plan_pro_001", "planTitle": "Plano Pro", "thresholdLevel": 75, "currentUsage": 3750, "usageLimit": 5000, "usagePercentage": 0.75, "hasPaymentMethod": true } } ``` *** ### Limite de Assinatura Não Pago [Seção intitulada “Limite de Assinatura Não Pago”](#limite-de-assinatura-não-pago) **Nome do Evento:** `subscription_unpaid_limit` **Quando é Acionado:** * Quando o uso atinge 100% do limite do último tier **E** o pagamento está vencido * Alerta crítico indicando que a conta pode ser suspensa * Fatura está em status “past\_due” ou “unpaid” **Payload:** | Campo | Tipo | Obrigatório | Descrição | | ------------------ | ------- | ----------- | ---------------------------------------------------------- | | `userId` | string | Sim | Identificador único do usuário (UUID) | | `accountId` | string | Sim | Identificador único da conta (UUID) | | `name` | string | Não | Nome completo do usuário | | `contactEmail` | string | Não | Email de contato do usuário | | `contactPhone` | string | Não | Número de telefone de contato do usuário | | `planId` | string | Sim | Identificador único do plano atual | | `planTitle` | string | Sim | Nome de exibição do plano atual | | `currentUsage` | number | Sim | Contagem de uso atual | | `usageLimit` | number | Sim | Limite máximo de uso do tier atual | | `invoiceStatus` | string | Sim | Status da fatura (ex: “past\_due”, “unpaid”) | | `invoiceDueAt` | string | Sim | Data de vencimento da fatura (formato ISO 8601) | | `gracePeriodEnd` | string | Não | Data fim do período de tolerância (formato ISO 8601) | | `hasPaymentMethod` | boolean | Sim | Se a conta possui método de pagamento registrado no Stripe | **Status de Fatura:** * `past_due`: Pagamento está vencido * `unpaid`: Fatura não foi paga **Exemplo:** ```json { "name": "subscription_unpaid_limit", "request_id": "550e8400-e29b-41d4-a716-446655440007", "idempotence_key": "subscription_550e8400_unpaid_1706438900", "created_at": "2024-01-28T10:38:20.000Z", "payload": { "userId": "550e8400-e29b-41d4-a716-446655440001", "accountId": "550e8400-e29b-41d4-a716-446655440002", "name": "João Silva", "contactEmail": "joao@exemplo.com", "contactPhone": "+5511999999999", "planId": "plan_pro_001", "planTitle": "Plano Pro", "currentUsage": 5000, "usageLimit": 5000, "invoiceStatus": "past_due", "invoiceDueAt": "2024-01-25T00:00:00.000Z", "gracePeriodEnd": "2024-02-01T00:00:00.000Z", "hasPaymentMethod": false } } ``` *** ## Integração com N8N [Seção intitulada “Integração com N8N”](#integração-com-n8n) ### Configuração do Webhook [Seção intitulada “Configuração do Webhook”](#configuração-do-webhook) Para receber esses eventos no N8N: 1. Crie um nó **Webhook** em seu fluxo de trabalho N8N 2. Defina o Método HTTP como **POST** 3. Use a URL do webhook na configuração do backend Gaio 4. Configure autenticação se necessário (recomendado) ### Roteamento de Eventos [Seção intitulada “Roteamento de Eventos”](#roteamento-de-eventos) Você pode rotear eventos com base no campo `name`: ```javascript // Exemplo de condição Switch/Router no N8N if (event.name === "user_data_filled") { // Rotear para fluxo de onboarding de usuário } else if (event.name === "account_connected") { // Rotear para fluxo de mensagem de boas-vindas } else if (event.name === "subscription_usage_threshold") { // Rotear para fluxo de aviso de uso } // ... etc ``` ### Idempotência [Seção intitulada “Idempotência”](#idempotência) Sempre use a `idempotence_key` para prevenir processamento duplicado: 1. Armazene a `idempotence_key` ao processar um evento 2. Verifique se a chave existe antes de processar 3. Pule o processamento se a chave já foi processada **Exemplo de Nó de Código N8N:** ```javascript const event = $input.all()[0].json; const idempotenceKey = event.idempotence_key; // Verificar se já foi processado (pseudo-código) const alreadyProcessed = await checkIfProcessed(idempotenceKey); if (alreadyProcessed) { return { skip: true }; } // Processar evento await processEvent(event); // Marcar como processado await markAsProcessed(idempotenceKey); return { processed: true }; ``` *** ## Testes [Seção intitulada “Testes”](#testes) Para testar a entrega de eventos, use o endpoint de teste de notificação: ```bash POST /api/console/notification-test ``` Este endpoint permite que você acione eventos de teste para fins de desenvolvimento e integração. # Visão Geral > Arquitetura e conceitos do backend GAIO Documentação do backend do ecossistema GAIO. ## Índice [Seção intitulada “Índice”](#índice) * [Sistema de Permissões](/backend/permissions/) — Scopes, roles, authorizers e verificação de permissões * [Notificações N8N](/backend/notifications/) — Eventos de notificação e integração com webhooks N8N * [Internacionalização (i18n)](/backend/i18n/) — Sistema de internacionalização do backend * [ManyChat Importer](/backend/manychat-importer/) — Importador de workflows do ManyChat # Sistema de Permissões > Permissões, scopes, roles e autorização no backend ## Índice [Seção intitulada “Índice”](#índice) * [Visão Geral](#vis%C3%A3o-geral) * [Estrutura de Dados](#estrutura-de-dados) * [Authorizers](#authorizers) * [Verificação de Permissões](#verifica%C3%A7%C3%A3o-de-permiss%C3%B5es) * [Erros de Autorização](#erros-de-autoriza%C3%A7%C3%A3o) * [Uso em Endpoints](#uso-em-endpoints) * [Fluxo de Autenticação](#fluxo-de-autentica%C3%A7%C3%A3o) * [Referência de Permissões por Feature](#refer%C3%AAncia-de-permiss%C3%B5es-por-feature) *** ## Visão Geral [Seção intitulada “Visão Geral”](#visão-geral) O sistema de permissões utiliza uma abordagem híbrida: * **TokenScope**: Define o nível de acesso hierárquico (Admin, Owner, User) * **Permission**: Permissões granulares para colaboradores (Users) * **CollaborationRole**: Papel do usuário na colaboração ### Regra Principal [Seção intitulada “Regra Principal”](#regra-principal) | Tipo de Usuário | Comportamento | | ---------------------- | ----------------------------------------------------------- | | **Admin/Owner** | Acesso total automático - ignora verificação de permissions | | **User (Colaborador)** | Acesso controlado - verifica array de permissions | *** ## Estrutura de Dados [Seção intitulada “Estrutura de Dados”](#estrutura-de-dados) ### TokenScope [Seção intitulada “TokenScope”](#tokenscope) packages/common/src/auth/types.ts ```typescript const enum TokenScope { Refresh = "refresh", // Apenas para renovar tokens User = "user", // Colaborador com permissões limitadas Owner = "owner", // Dono da conta - acesso total Admin = "admin", // Administrador do sistema - acesso total + funções admin } ``` ### Permission [Seção intitulada “Permission”](#permission) packages/common/src/auth/types.ts ```typescript const enum Permission { DASHBOARD = "dashboard", INBOX = "inbox", LIVECHAT = "livechat", CONTACTS = "contacts", RANKING = "ranking", WORKFLOWS = "workflows", TEMPLATES = "templates", SETTINGS = "settings", TEAM = "team", BILLING = "billing", } const ALL_PERMISSIONS: Permission[] = [ Permission.DASHBOARD, Permission.INBOX, Permission.LIVECHAT, Permission.CONTACTS, Permission.RANKING, Permission.WORKFLOWS, Permission.TEMPLATES, Permission.SETTINGS, Permission.TEAM, Permission.BILLING, ]; ``` ### CollaborationRole [Seção intitulada “CollaborationRole”](#collaborationrole) packages/console/src/collaborations/types.ts ```typescript const enum CollaborationRole { User = "user", // Colaborador padrão Owner = "owner", // Proprietário } ``` ### UserIdentity [Seção intitulada “UserIdentity”](#useridentity) packages/common/src/auth/types.ts ```typescript type UserIdentity = { scopes: TokenScope[]; permissions: Permission[]; accountId: string; userId: string; }; ``` *** ## Authorizers [Seção intitulada “Authorizers”](#authorizers) Os authorizers são funções que validam o token JWT e extraem a identity do usuário. ### Tipos Disponíveis [Seção intitulada “Tipos Disponíveis”](#tipos-disponíveis) | Authorizer | Scopes Aceitos | Uso | | ------------------ | ------------------------ | ---------------------------------- | | `publicUsers` | `Admin`, `Owner`, `User` | Operações gerais | | `publicOwnerUsers` | `Admin`, `Owner` | Gerenciamento de equipe, billing | | `publicAdminUsers` | `Admin` | Funções administrativas do sistema | ### Implementação [Seção intitulada “Implementação”](#implementação) public-user.ts ```typescript export function publicUsers( request: AuthorizeHeaderRequest, ): AuthorizeResponse { const token = getAuthorizationToken(request.headers); const identity = parseToken(token, [ TokenScope.Admin, TokenScope.Owner, TokenScope.User, ]); return { identity }; } ``` public-owner.ts ```typescript export function publicOwnerUsers( request: AuthorizeHeaderRequest, ): AuthorizeResponse { const token = getAuthorizationToken(request.headers); const identity = parseToken(token, [TokenScope.Admin, TokenScope.Owner]); return { identity }; } ``` public-admin.ts ```typescript export function publicAdminUsers( request: AuthorizeHeaderRequest, ): AuthorizeResponse { const token = getAuthorizationToken(request.headers); const identity = parseToken(token, [TokenScope.Admin]); return { identity }; } ``` ### Uso nas Rotas [Seção intitulada “Uso nas Rotas”](#uso-nas-rotas) routes.ts ```typescript export type InvitationsRoutes = [ { path: "POST /api/console/create-invitation"; authorizer: typeof publicUsers; // Qualquer usuário pode chamar handler: typeof createInvitationHandler; }, { path: "PUT /api/console/update-user-permissions/{userId}"; authorizer: typeof publicOwnerUsers; // Apenas owner/admin handler: typeof updateUserPermissionsHandler; }, ]; ``` *** ## Verificação de Permissões [Seção intitulada “Verificação de Permissões”](#verificação-de-permissões) ### Função `requirePermission` [Seção intitulada “Função requirePermission”](#função-requirepermission) packages/console/src/permissions/utils/check-permission.ts ```typescript import { HttpForbiddenError } from "@ez4/gateway"; import { TokenScope, type Permission, type UserIdentity } from "@gaio/common"; export const requirePermission = ( identity: UserIdentity, requiredPermission: Permission, ): void => { // Admin e Owner sempre têm acesso if (hasElevatedScope(identity.scopes)) { return; } // User precisa ter a permissão específica if (!identity.permissions?.includes(requiredPermission)) { throw new HttpForbiddenError(`Missing permission: ${requiredPermission}`); } }; const hasElevatedScope = (scopes: TokenScope[]): boolean => { return scopes.includes(TokenScope.Admin) || scopes.includes(TokenScope.Owner); }; ``` ### Lógica de Verificação de Permissões [Seção intitulada “Lógica de Verificação de Permissões”](#lógica-de-verificação-de-permissões) ```plaintext 1. Verifica se scope = 'Admin' ou 'Owner' └─ Sim → Acesso liberado (return) └─ Não → Continua 2. Verifica se permissions[] contém a permissão requerida └─ Sim → Acesso liberado (return) └─ Não → Lança HttpForbiddenError (403) ``` *** ## Erros de Autorização [Seção intitulada “Erros de Autorização”](#erros-de-autorização) ### Códigos HTTP [Seção intitulada “Códigos HTTP”](#códigos-http) | Código | Tipo | Quando Ocorre | | -------------------- | ------------ | ------------------------------------- | | **401 Unauthorized** | Autenticação | Token inválido, expirado ou ausente | | **403 Forbidden** | Autorização | Usuário autenticado mas sem permissão | ### Erro 403 - Forbidden [Seção intitulada “Erro 403 - Forbidden”](#erro-403---forbidden) O erro 403 é retornado quando: 1. **Scope insuficiente**: Usuário tenta acessar rota que exige scope maior * Exemplo: Usuário tentando acessar rota com `publicOwnerUsers` 2. **Permission ausente**: Usuário (scope User) não possui a permission requerida * Exemplo: Usuário sem `TEAM` tentando criar convite ### Formato da Resposta [Seção intitulada “Formato da Resposta”](#formato-da-resposta) ```json { "message": "Missing permission: team" } ``` ### Cenários [Seção intitulada “Cenários”](#cenários) | Cenário | Resultado | | --------------------------------------------------------- | ------------------------------------- | | Usuário sem `TEAM` chama `POST /create-invitation` | `403` - Missing permission: team | | Usuário sem `WORKFLOWS` chama `POST /create-workflow` | `403` - Missing permission: workflows | | Usuário tenta `PUT /update-user-permissions` (rota Owner) | `403` - Forbidden | | Token expirado em qualquer rota | `401` - Unauthorized | ### Tratamento no Cliente [Seção intitulada “Tratamento no Cliente”](#tratamento-no-cliente) ```typescript try { await api.createInvitation(data); } catch (error) { if (error.status === 401) { // Redirecionar para login - token inválido redirectToLogin(); } else if (error.status === 403) { // Mostrar mensagem de permissão negada showError("Você não tem permissão para esta ação"); } } ``` *** ## Uso em Endpoints [Seção intitulada “Uso em Endpoints”](#uso-em-endpoints) ### Exemplo Básico [Seção intitulada “Exemplo Básico”](#exemplo-básico) ```typescript import { Permission } from "@gaio/common"; import { requirePermission } from "../../permissions/utils/check-permission.js"; export async function createInvitationHandler( request: CreateInvitationRequest, context: Service.Context, ): Promise { // Verifica permissão ANTES de qualquer operação requirePermission(request.identity, Permission.TEAM); const { accountId, userId } = request.identity; // ... resto da implementação } ``` ### Exemplo com Múltiplas Verificações [Seção intitulada “Exemplo com Múltiplas Verificações”](#exemplo-com-múltiplas-verificações) ```typescript export async function updateWorkflowHandler( request: UpdateWorkflowRequest, context: Service.Context, ): Promise { requirePermission(request.identity, Permission.WORKFLOWS); const { accountId } = request.identity; const { workflowId } = request.parameters; // Se for template, precisa de permissão adicional const workflow = await readWorkflow(consoleDb, accountId, workflowId); if (workflow.is_template) { requirePermission(request.identity, Permission.TEMPLATES); } // ... resto da implementação } ``` ### Permissões por Domínio [Seção intitulada “Permissões por Domínio”](#permissões-por-domínio) | Domínio | Permission | Descrição | | -------------- | ----------- | ------------------------------ | | Tags | `CONTACTS` | CRUD de tags | | Contacts | `CONTACTS` | CRUD de contatos | | Workflows | `WORKFLOWS` | CRUD de workflows | | Replies | `WORKFLOWS` | Respostas rápidas | | Templates | `TEMPLATES` | Marketplace | | Invitations | `TEAM` | Convites de equipe | | Collaborations | `TEAM` | Gerenciamento de colaboradores | | Settings | `SETTINGS` | Configurações da conta | | Billing | `BILLING` | Faturamento | *** ## Fluxo de Autenticação [Seção intitulada “Fluxo de Autenticação”](#fluxo-de-autenticação) ### Geração de Token [Seção intitulada “Geração de Token”](#geração-de-token) packages/console/src/authentications/utils/token.ts ```typescript export const issueToken = ( accountId: string, userId: string, scopes: TokenScope[], permissions: Permission[] = [], options?: IssueTokenOptions, ) => { const accessPayload = { iss: accountId, sub: userId, scopes, permissions, }; return { accessToken: jwt.sign(accessPayload, issuerSecret, { expiresIn: issuerAccessTTL, }), refreshToken: jwt.sign(refreshPayload, issuerSecret, { expiresIn: issuerRefreshTTL, }), }; }; ``` ### Determinação de Scope e Permissions [Seção intitulada “Determinação de Scope e Permissions”](#determinação-de-scope-e-permissions) packages/console/src/authentications/repository.ts ```typescript export const fetchUserScopeAndPermissions = async ( client: DbClient, accountId: string, userId: string, ): Promise => { const [account, collaboration] = await Promise.all([ // Verifica se usuário e owner da conta client.accounts.findOne({ select: { id: true, status: true, type: true }, where: { deleted_at: null, owner_user_id: userId, id: accountId }, }), // Verifica se usuário tem colaboração client.collaborations.findOne({ select: { role: true }, where: { deleted_at: null, account_id: accountId, user_id: userId }, }), ]); // Owner da conta: todas as permissões if (account) { return { scope: getAccountScope(account), // owner ou admin permissions: getAllPermissions(), }; } // Colaborador: permissões do banco if (collaboration) { const permissions = await fetchUserPermissions(client, accountId, userId); return { scope: roleToScope(collaboration.role), // user permissions, }; } return undefined; }; ``` ### Mapeamento Account → Scope [Seção intitulada “Mapeamento Account → Scope”](#mapeamento-account--scope) ```typescript const getAccountScope = (account: Pick) => { if (account.type === AccountType.System) { return TokenScope.Admin; } return TokenScope.Owner; }; ``` ### Mapeamento Role → Scope [Seção intitulada “Mapeamento Role → Scope”](#mapeamento-role--scope) ```typescript export const roleToScope = (role: CollaborationRole) => { switch (role) { case CollaborationRole.Owner: return TokenScope.Owner; default: return TokenScope.User; } }; ``` *** ## Referência de Permissões por Feature [Seção intitulada “Referência de Permissões por Feature”](#referência-de-permissões-por-feature) ### Contatos [Seção intitulada “Contatos”](#contatos) tags/endpoints/create.ts ```typescript requirePermission(request.identity, Permission.CONTACTS); // tags/endpoints/update.ts requirePermission(request.identity, Permission.CONTACTS); // tags/endpoints/delete.ts requirePermission(request.identity, Permission.CONTACTS); // contacts/endpoints/*.ts requirePermission(request.identity, Permission.CONTACTS); ``` ### Workflows [Seção intitulada “Workflows”](#workflows) ```typescript // workflows/endpoints/*.ts requirePermission(request.identity, Permission.WORKFLOWS); // replies/endpoints/*.ts requirePermission(request.identity, Permission.WORKFLOWS); ``` ### Templates [Seção intitulada “Templates”](#templates) template-packages/endpoints/list-created.ts ```typescript requirePermission(request.identity, Permission.TEMPLATES); // template-packages/endpoints/install.ts requirePermission(request.identity, Permission.TEMPLATES); ``` ### Team [Seção intitulada “Team”](#team) invitations/endpoints/create.ts ```typescript requirePermission(request.identity, Permission.TEAM); // invitations/endpoints/update.ts requirePermission(request.identity, Permission.TEAM); // collaborations/endpoints/update.ts requirePermission(request.identity, Permission.TEAM); ``` *** ## Endpoints de Gerenciamento de Permissões [Seção intitulada “Endpoints de Gerenciamento de Permissões”](#endpoints-de-gerenciamento-de-permissões) ### Listar Todas as Permissões [Seção intitulada “Listar Todas as Permissões”](#listar-todas-as-permissões) ```http GET /api/console/list-permissions Authorizer: publicUsers ``` Retorna array com todas as permissões disponíveis (`ALL_PERMISSIONS`). ### Ler Permissões de Usuário [Seção intitulada “Ler Permissões de Usuário”](#ler-permissões-de-usuário) ```http GET /api/console/read-user-permissions/{userId} Authorizer: publicOwnerUsers ``` Retorna as permissões atribuídas a um colaborador específico. ### Atualizar Permissões de Usuário [Seção intitulada “Atualizar Permissões de Usuário”](#atualizar-permissões-de-usuário) ```http PUT /api/console/update-user-permissions/{userId} Authorizer: publicOwnerUsers Body: { "permissions": ["inbox", "contacts"] } ``` Define as permissões de um colaborador (substitui todas). *** ## Checklist para Novos Endpoints [Seção intitulada “Checklist para Novos Endpoints”](#checklist-para-novos-endpoints) * [ ] Definir authorizer apropriado na rota (`publicUsers`, `publicOwnerUsers`, `publicAdminUsers`) * [ ] Adicionar `requirePermission()` no início do handler se necessário * [ ] Usar a Permission correta para o domínio * [ ] Testar com usuário Owner (deve ter acesso) * [ ] Testar com usuário User sem a permissão (deve retornar 403) * [ ] Testar com usuário User com a permissão (deve ter acesso) # Visão Geral > Comandos e ferramentas The Gaio CLI is a command-line interface tool designed to streamline the setup and management of development environments for Gaio projects. It provides commands to automate environment setup, generate LLM documentation, and validate configurations. ## 🚀 Quick Start [Seção intitulada “🚀 Quick Start”](#-quick-start) After cloning the repository and running `npm install`, the CLI is automatically available: ```bash npm install # Auto-setup CLI and development environment gaio --help # See available commands gaio status # Check current setup status ``` ## 📋 Available Commands [Seção intitulada “📋 Available Commands”](#-available-commands) ### `gaio setup` / `gaio s` [Seção intitulada “gaio setup / gaio s”](#gaio-setup--gaio-s) Setup the development environment and generate LLM documentation with intelligent validation. ```bash gaio setup # Smart setup (skips if already configured) gaio setup --force # Force complete re-setup gaio setup --status # Show setup status only ``` ### `gaio llm` / `gaio l` [Seção intitulada “gaio llm / gaio l”](#gaio-llm--gaio-l) Generate LLM documentation from the `docs/` directory. ```bash gaio llm ``` ### `gaio status` [Seção intitulada “gaio status”](#gaio-status) Show CLI setup status and configuration information. ```bash gaio status ``` ### `gaio help` / `gaio h` [Seção intitulada “gaio help / gaio h”](#gaio-help--gaio-h) Show enhanced help information with examples. ```bash gaio help ``` ## ⚙️ Configuration [Seção intitulada “⚙️ Configuration”](#️-configuration) The CLI automatically detects the project structure: * **Source**: `docs/` for LLM documentation * **Entry Points**: `AGENTS.md` and `CLAUDE.md` symbolic links * **State**: `.gaio.json` for setup validation (includes hash for docs change detection) ## 🛠️ Development [Seção intitulada “🛠️ Development”](#️-development) ### Building [Seção intitulada “Building”](#building) ```bash npm run build -w @gaio/cli # Build CLI from TypeScript npm run build:watch -w @gaio/cli # Watch mode for development ``` ### Development Mode [Seção intitulada “Development Mode”](#development-mode) ```bash npm run dev -w @gaio/cli # Run CLI in development with tsx npm run dev:watch -w @gaio/cli # Development with hot reload ``` ### Testing [Seção intitulada “Testing”](#testing) ```bash npm run test -w @gaio/cli # Run CLI tests npm run test:commands -w @gaio/cli # Test all CLI commands ``` ### Type Checking [Seção intitulada “Type Checking”](#type-checking) ```bash npm run typecheck -w @gaio/cli # TypeScript type checking ``` ## 🔧 How It Works [Seção intitulada “🔧 How It Works”](#-how-it-works) 1. **Installation**: `npm install` triggers `postinstall` script 2. **Setup Detection**: Checks for existing setup state and files 3. **Project Root Finding**: Intelligently locates monorepo root 4. **LLM Generation**: Processes documentation files and creates entry points 5. **State Saving**: Records successful setup for future validation ## 🎯 Best Practices [Seção intitulada “🎯 Best Practices”](#-best-practices) ### For Development [Seção intitulada “For Development”](#for-development) * Use `npm run dev -w @gaio/cli` for development work * Run `gaio setup` after pulling major changes * Use `gaio status` to debug setup issues * Use `--force` flag only when necessary changes are made * Check `docs/` directory structure if LLM generation fails ### For CI/CD [Seção intitulada “For CI/CD”](#for-cicd) * Use `gaio setup --force` in CI environments for consistency * Validate setup with `gaio status` before running commands * Cache `.llm/` directory to speed up builds when possible ## 🚨 Troubleshooting [Seção intitulada “🚨 Troubleshooting”](#-troubleshooting) ### CLI Not Found [Seção intitulada “CLI Not Found”](#cli-not-found) ```bash npm install --workspace=@gaio/cli # Reinstall CLI workspace npm run build -w @gaio/cli # Rebuild from source ``` ### Permission Denied [Seção intitulada “Permission Denied”](#permission-denied) ```bash chmod +x tools/cli/bin/cli.js # Fix executable permissions npm run build -w @gaio/cli # Rebuild with proper permissions ``` ### Setup Not Working [Seção intitulada “Setup Not Working”](#setup-not-working) ```bash gaio status # Check setup status gaio setup --force # Force complete re-setup rm .gaio.json && gaio setup # Reset and re-setup ``` ### Project Root Not Found [Seção intitulada “Project Root Not Found”](#project-root-not-found) ```bash # Ensure you're in a valid Gaio project directory ls package.json docs/ # Verify required files exist gaio setup --force # Force re-detection ``` # Token Refresh > Sistema de coordenação de refresh de tokens com single-flight pattern. O `RefreshCoordinatorService` é um componente core que gerencia o refresh de tokens de autenticação (access token e impersonate token) com **deduplicação automática** de requisições concorrentes. *** ## Visão Geral [Seção intitulada “Visão Geral”](#visão-geral) ### Problema que Resolve [Seção intitulada “Problema que Resolve”](#problema-que-resolve) Quando múltiplas requests simultâneas detectam que o token expirou, **sem coordenação** cada uma tentaria fazer refresh independentemente: ```typescript // ❌ SEM COORDENAÇÃO (problema) Request A: token expirado → POST /refresh-token Request B: token expirado → POST /refresh-token // duplicado! Request C: token expirado → POST /refresh-token // duplicado! // Resultado: 3 chamadas ao servidor, race conditions, tokens inconsistentes ``` ```typescript // ✅ COM COORDENAÇÃO (solução) Request A: token expirado → POST /refresh-token Request B: token expirado → aguarda o refresh de A Request C: token expirado → aguarda o refresh de A // Resultado: 1 chamada, todas recebem o mesmo token ``` ### Conceito: Single-Flight Pattern [Seção intitulada “Conceito: Single-Flight Pattern”](#conceito-single-flight-pattern) Implementa o padrão **single-flight** (também conhecido como request deduplication): > “Múltiplas operações idênticas são consolidadas em uma única execução, com todas as chamadas compartilhando o mesmo resultado.” **Benefícios:** * Previne race conditions * Reduz carga no servidor (1 request ao invés de N) * Melhora latência (requests não esperam na fila HTTP) * Garante consistência (todos recebem o mesmo token) *** ## Por Que Existe? [Seção intitulada “Por Que Existe?”](#por-que-existe) ### Contexto: Sistema de Impersonate [Seção intitulada “Contexto: Sistema de Impersonate”](#contexto-sistema-de-impersonate) No Gaio Console, admins podem “impersonate” usuários finais: 1. **Admin faz login** → recebe `accessToken` 2. **Admin impersona User X** → recebe `impersonateToken` para User X 3. **Todas as requests** usam `impersonateToken` (se existir) ou `accessToken` ### Problema Real: Concurrent Polling [Seção intitulada “Problema Real: Concurrent Polling”](#problema-real-concurrent-polling) Cenário comum no app: ```typescript setInterval(() => fetchChats(), 5000); // Request A setInterval(() => fetchMessages(), 5000); // Request B setInterval(() => fetchNotifications(), 5000); // Request C ``` **O que acontece quando `impersonateToken` expira?** ```plaintext t=0: Requests A, B, C detectam token expirado t=0: A, B, C tentam fazer refresh simultaneamente t=100: Servidor recebe 3 requests de refresh idênticas t=200: 3 novos tokens gerados (race condition!) t=300: Apenas o último token salvo é válido → Requests que salvaram tokens anteriores FALHAM → Usuário é deslogado indevidamente ``` ### Solução: RefreshCoordinatorService [Seção intitulada “Solução: RefreshCoordinatorService”](#solução-refreshcoordinatorservice) ```plaintext t=0: Requests A, B, C detectam token expirado t=0: A chama runSingleFlight() → inicia refresh t=1: B chama runSingleFlight() → retorna promise de A (dedup!) t=2: C chama runSingleFlight() → retorna promise de A (dedup!) t=100: Servidor recebe 1 request t=200: 1 novo token gerado t=300: A, B, C recebem o mesmo token Todas as requests continuam com token válido ``` *** ## Arquitetura [Seção intitulada “Arquitetura”](#arquitetura) ### Componentes do Sistema [Seção intitulada “Componentes do Sistema”](#componentes-do-sistema) ```plaintext ┌─────────────────────────────────────────────────────────────┐ │ Application Layer │ ├─────────────────────────────────────────────────────────────┤ │ Components fazendo requests autenticadas │ │ (Chat, Messages, Notifications, etc.) │ └────────────────┬────────────────────────────────────────────┘ │ ▼ ┌─────────────────────────────────────────────────────────────┐ │ Axios Private API Instance │ ├─────────────────────────────────────────────────────────────┤ │ Request Interceptor: │ │ 1. await refreshCoordinator.waitForActiveRefresh() │ │ 2. add Authorization header │ │ │ │ Response Interceptor: │ │ - Handle 401 → retry with refreshed token │ │ - Handle 403 → redirect │ └────────────────┬────────────────────────────────────────────┘ │ ▼ ┌─────────────────────────────────────────────────────────────┐ │ Session Service │ ├─────────────────────────────────────────────────────────────┤ │ getSessionToken(): │ │ 1. Check refresh token validity │ │ 2. Check access token validity → refresh if needed │ │ 3. Check impersonate token validity │ │ ├─ if expired → runSingleFlight(refresh) │ │ └─ if valid → return it │ └────────────────┬────────────────────────────────────────────┘ │ ▼ ┌─────────────────────────────────────────────────────────────┐ │ RefreshCoordinatorService │ ├─────────────────────────────────────────────────────────────┤ │ runSingleFlight(): │ │ - If refresh active → return existing promise │ │ - Else → start new refresh → track promise │ │ │ │ waitForActiveRefresh(): │ │ - If refresh active → wait (with 3s timeout) │ │ - Else → return null (continue immediately) │ └─────────────────────────────────────────────────────────────┘ ``` *** ## Guia de Uso [Seção intitulada “Guia de Uso”](#guia-de-uso) ### Uso Básico (Atual no Projeto) [Seção intitulada “Uso Básico (Atual no Projeto)”](#uso-básico-atual-no-projeto) session.service.ts ```typescript import { refreshCoordinatorService } from "@/core/auth/services/refresh-coordinator.service"; export const getSessionToken = async (): Promise => { // ... validações de refresh token e access token ... if (impersonateToken && !isValid(impersonateToken)) { const freshToken = await refreshCoordinatorService.runSingleFlight( async () => { const response = await consoleApi.post<{ impersonate_token: string }>( `/impersonate/${accountId}`, null, { headers: { Authorization: `Bearer ${accessToken}` } }, ); return response.data.impersonate_token; }, ); storeSession({ accessToken, refreshToken, impersonateToken: freshToken }); return freshToken; } return impersonateToken ?? accessToken; }; ``` private-api.ts ```typescript import { RefreshWaitTimeoutError, refreshCoordinatorService, } from "@/core/auth/services/refresh-coordinator.service"; privateApi.interceptors.request.use(async (config) => { try { await refreshCoordinatorService.waitForActiveRefresh(); } catch (error) { if (error instanceof RefreshWaitTimeoutError) { throw error; } throw error; } const headers = await getSessionHeaders(); config.headers.set("Authorization", headers.Authorization); return config; }); ``` ### Uso Avançado (Custom Coordinator) [Seção intitulada “Uso Avançado (Custom Coordinator)”](#uso-avançado-custom-coordinator) ```typescript import { createRefreshCoordinator } from "@/core/auth/services/refresh-coordinator.service"; const customCoordinator = createRefreshCoordinator(); await customCoordinator.runSingleFlight(async () => { // sua lógica de refresh }); ``` *** ## API Completa [Seção intitulada “API Completa”](#api-completa) ### `runSingleFlight(runner: () => Promise): Promise` [Seção intitulada “runSingleFlight\(runner: () => Promise\): Promise\”](#runsingleflighttdatarunner---promisetdata-promisetdata) Executa lógica de refresh com deduplicação automática. **Comportamento:** 1. Se **nenhum refresh ativo** → executa `runner()` e retorna o resultado 2. Se **refresh já ativo** → retorna a promise do refresh ativo (sem executar `runner()`) ```typescript const [token1, token2, token3] = await Promise.all([ refreshCoordinator.runSingleFlight(() => fetchNewToken()), refreshCoordinator.runSingleFlight(() => fetchNewToken()), refreshCoordinator.runSingleFlight(() => fetchNewToken()), ]); // fetchNewToken() foi chamado apenas 1 vez! console.log(token1 === token2); // true ``` ### `waitForActiveRefresh(): Promise` [Seção intitulada “waitForActiveRefresh\(): Promise\”](#waitforactiverefreshtdata-promisetdata--null) Aguarda refresh ativo completar, ou retorna imediatamente se nenhum refresh está acontecendo. **Throws:** `RefreshWaitTimeoutError` se refresh não completar em 3000ms. ### `isRefreshing(): boolean` [Seção intitulada “isRefreshing(): boolean”](#isrefreshing-boolean) Verifica se refresh está ativo no momento. ### `reset(): void` [Seção intitulada “reset(): void”](#reset-void) Reseta o estado interno do coordinator. **Apenas em testes!** ### `RefreshWaitTimeoutError` [Seção intitulada “RefreshWaitTimeoutError”](#refreshwaittimeouterror) Erro lançado quando `waitForActiveRefresh()` excede o timeout. *** ## Patterns & Best Practices [Seção intitulada “Patterns & Best Practices”](#patterns--best-practices) ### Use `runSingleFlight` para Todas as Operações de Refresh [Seção intitulada “Use runSingleFlight para Todas as Operações de Refresh”](#use-runsingleflight-para-todas-as-operações-de-refresh) ```typescript // ✅ CORRETO await refreshCoordinator.runSingleFlight(async () => { const token = await api.post("/refresh"); localStorage.setItem("token", token); return token; }); // ❌ ERRADO (sem coordenação) const token = await api.post("/refresh"); localStorage.setItem("token", token); ``` ### Use `waitForActiveRefresh` em Request Interceptors [Seção intitulada “Use waitForActiveRefresh em Request Interceptors”](#use-waitforactiverefresh-em-request-interceptors) ```typescript // ✅ CORRETO axiosInstance.interceptors.request.use(async (config) => { await refreshCoordinator.waitForActiveRefresh(); const token = getTokenFromStorage(); config.headers.Authorization = `Bearer ${token}`; return config; }); ``` ### Handle `RefreshWaitTimeoutError` Gracefully [Seção intitulada “Handle RefreshWaitTimeoutError Gracefully”](#handle-refreshwaittimeouterror-gracefully) ```typescript try { await refreshCoordinator.waitForActiveRefresh(); } catch (error) { if (error instanceof RefreshWaitTimeoutError) { console.error("Refresh timeout - forcing logout"); forceLogout(); } throw error; } ``` ### Propagar Erros de Refresh [Seção intitulada “Propagar Erros de Refresh”](#propagar-erros-de-refresh) ```typescript // ✅ CORRETO - propaga para todos os waiters await refreshCoordinator.runSingleFlight(async () => { try { const token = await api.post("/refresh"); return token; } catch (error) { trackError("refresh_failed", error); throw error; } }); // ❌ ERRADO - engole erro await refreshCoordinator.runSingleFlight(async () => { try { return await api.post("/refresh"); } catch (error) { return null; // todos os waiters receberão null } }); ``` *** ## Testing [Seção intitulada “Testing”](#testing) ### Setup de Testes [Seção intitulada “Setup de Testes”](#setup-de-testes) ```typescript import { refreshCoordinatorService } from "@/core/auth/services/refresh-coordinator.service"; describe("Session Refresh", () => { afterEach(() => { refreshCoordinatorService.reset(); }); }); ``` ### Padrão: Mock de Refresh com Delay [Seção intitulada “Padrão: Mock de Refresh com Delay”](#padrão-mock-de-refresh-com-delay) ```typescript it("should deduplicate concurrent impersonate refresh calls", async () => { let callCount = 0; const mockRefresh = vi.fn(async () => { callCount++; await new Promise((resolve) => setTimeout(resolve, 100)); return "new-token"; }); const [token1, token2, token3] = await Promise.all([ refreshCoordinatorService.runSingleFlight(mockRefresh), refreshCoordinatorService.runSingleFlight(mockRefresh), refreshCoordinatorService.runSingleFlight(mockRefresh), ]); expect(callCount).toBe(1); expect(token1).toBe("new-token"); expect(token2).toBe("new-token"); expect(token3).toBe("new-token"); expect(mockRefresh).toHaveBeenCalledTimes(1); }); ``` *** ## Troubleshooting [Seção intitulada “Troubleshooting”](#troubleshooting) ### Usuário Deslogado Durante Polling [Seção intitulada “Usuário Deslogado Durante Polling”](#usuário-deslogado-durante-polling) **Sintoma:** User is randomly logged out while using the app **Causa:** Multiple polling requests tentando refresh simultaneamente sem coordenação. **Solução:** Certifique-se de que TODO refresh passa por `runSingleFlight()`. ### Requests Travando por 3 Segundos [Seção intitulada “Requests Travando por 3 Segundos”](#requests-travando-por-3-segundos) **Sintoma:** `RefreshWaitTimeoutError` in console **Causa:** Refresh está demorando muito (> 3s) ou travado. **Soluções:** 1. Aumente timeout: modifique `SESSION_REFRESH_LIMITS.REQUEST_WAIT_TIMEOUT_MS` 2. Otimize endpoint no backend ### Token Inconsistente Entre Requests [Seção intitulada “Token Inconsistente Entre Requests”](#token-inconsistente-entre-requests) **Causa:** Race condition no save do token. **Solução:** Garanta que refresh retorna e salva token dentro do `runSingleFlight()`: ```typescript // ✅ CORRETO await refreshCoordinator.runSingleFlight(async () => { const token = await api.post("/refresh"); localStorage.setItem("token", token); // dentro do flight return token; }); // ❌ ERRADO const token = await refreshCoordinator.runSingleFlight(async () => { return await api.post("/refresh"); }); localStorage.setItem("token", token); // fora do flight - race condition! ``` # Formulários > Guia completo para implementação de formulários na plataforma Gaio. Guia completo para implementação de formulários na plataforma Gaio. *** ## Índice [Seção intitulada “Índice”](#índice) * [Introdução](#introdu%C3%A7%C3%A3o) * [useForm](#useform) * [Field Components](#field-components) * [useWatch](#usewatch) * [useFieldArray](#usefieldarray) * [Validação com Zod](#valida%C3%A7%C3%A3o-com-zod) * [Padrões Avançados](#padr%C3%B5es-avan%C3%A7ados) * [Anti-patterns](#anti-patterns) *** ## Introdução [Seção intitulada “Introdução”](#introdução) ### Stack [Seção intitulada “Stack”](#stack) | Biblioteca | Versão | Propósito | | --------------------- | ------ | ---------------------------- | | `react-hook-form` | 7.x | Gerenciamento de formulários | | `zod` | 4.x | Validação de schemas | | `@gaio/components` | - | Hooks e componentes base | | `@hookform/resolvers` | - | Integração Zod + RHF | ### Arquitetura [Seção intitulada “Arquitetura”](#arquitetura) ```plaintext ┌─────────────────────────────────────────────────────────────┐ │ useForm │ │ (schema, defaultValues, masks, formatters) │ └─────────────────────────┬───────────────────────────────────┘ │ ▼ ┌─────────────────────────────────────────────────────────────┐ │
│ │ (FormProvider) │ └─────────────────────────┬───────────────────────────────────┘ │ ┌───────────────┼───────────────┐ ▼ ▼ ▼ ┌─────────────────┐ ┌───────────┐ ┌───────────────┐ │ InputField │ │SelectField│ │ useFieldArray │ │ (withController)│ │ │ │ │ └─────────────────┘ └───────────┘ └───────────────┘ ``` ### Imports Padrão [Seção intitulada “Imports Padrão”](#imports-padrão) ```typescript // De @gaio/components import { Form, useForm } from "@gaio/components"; // De react-hook-form (quando necessário) import { useWatch, useFieldArray } from "react-hook-form"; // Fields do console import { InputField } from "@/components/fields/input"; import { SelectField } from "@/components/fields/select"; ``` *** ## useForm [Seção intitulada “useForm”](#useform) O hook `useForm` é um wrapper do react-hook-form com integração Zod automática. **Localização:** `@gaio/components` ### Parâmetros [Seção intitulada “Parâmetros”](#parâmetros) | Parâmetro | Tipo | Obrigatório | Descrição | | --------------------- | ------------------------------- | ----------- | ---------------------------------- | | `schema` | `z.ZodObject` | Sim | Schema Zod para validação | | `defaultValues` | `DefaultValues` | Não | Valores iniciais do form | | `masks` | `Record` | Não | Máscaras por campo (CPF, telefone) | | `formatters` | `Record string>` | Não | Formatadores de valor | | `fieldReValidateMode` | `Record` | Não | Campos que revalidam onChange | | `disabled` | `boolean` | Não | Desabilita todo o form | | `context` | `Record` | Não | Contexto adicional | ### Uso Básico [Seção intitulada “Uso Básico”](#uso-básico) ```tsx import { z } from "zod"; import { Form, useForm } from "@gaio/components"; import { InputField } from "@/components/fields/input"; const ProfileSchema = z.object({ name: z.string().min(1, "Campo obrigatório"), email: z.email("E-mail inválido"), }); function ProfileForm() { const form = useForm({ schema: ProfileSchema, defaultValues: { name: "", email: "" }, }); const onSubmit = (data: z.infer) => { console.log(data); }; return ( ); } ``` ### Com Máscaras e Formatters [Seção intitulada “Com Máscaras e Formatters”](#com-máscaras-e-formatters) ```tsx import { cpfMask, phoneMask } from "@gaio/common/masks"; const form = useForm({ schema: ContactSchema, defaultValues: { cpf: "", phone: "" }, masks: { cpf: cpfMask, phone: phoneMask, }, formatters: { cpf: (value) => value.replace(/\D/g, ""), }, }); ``` ### Com Revalidação onChange [Seção intitulada “Com Revalidação onChange”](#com-revalidação-onchange) ```tsx const form = useForm({ schema: LoginSchema, fieldReValidateMode: { email: "onChange", }, }); ``` ### Retorno do useForm [Seção intitulada “Retorno do useForm”](#retorno-do-useform) | Método | Descrição | | ---------------------------------- | ------------------------------------------- | | `form.control` | Controller para passar aos fields | | `form.handleSubmit(fn)` | Wrapper de submit com validação | | `form.reset(values?)` | Reseta o form | | `form.setValue(name, value, opts)` | Define valor programaticamente | | `form.getValues(name?)` | Obtém valores atuais | | `form.watch(name?)` | Observa mudanças (cuidado!) | | `form.trigger(name?)` | Dispara validação manual | | `form.setError(name, error)` | Define erro manualmente | | `form.clearErrors(name?)` | Limpa erros | | `form.formState` | Estado: `isDirty`, `isSubmitting`, `errors` | *** ## Field Components [Seção intitulada “Field Components”](#field-components) ### Campos Disponíveis [Seção intitulada “Campos Disponíveis”](#campos-disponíveis) | Campo | Arquivo | Descrição | | ------------------- | ------------------------ | ------------------------------- | | `InputField` | `input.tsx` | Input de texto, email, password | | `TextareaField` | `textarea.tsx` | Textarea com auto-resize | | `NumberField` | `number.tsx` | Input numérico | | `PhoneField` | `phone-field.tsx` | Input de telefone | | `SelectField` | `select.tsx` | Select dropdown | | `MultiSelectField` | `multi-select-field.tsx` | Multi-select | | `ComboboxField` | `combobox.tsx` | Combobox com busca | | `CheckboxField` | `checkbox-field.tsx` | Checkbox simples | | `RadioGroupField` | `radio-group-field.tsx` | Grupo de radio buttons | | `SwitchField` | `switch.tsx` | Toggle switch | | `DateField` | `date.tsx` | Date picker | | `ListField` | `list.tsx` | Lista dinâmica de items | | `UploadField` | `upload.tsx` | Upload de arquivos | | `AutocompleteField` | `autocomplete-field.tsx` | Autocomplete async | ### Props Comuns [Seção intitulada “Props Comuns”](#props-comuns) Todos os fields aceitam estas props via `BaseFieldProps`: ```typescript type BaseFieldProps = { name: string; // Nome do campo no schema control: Control; // form.control label?: string; // Label acima do campo labelAddon?: ReactNode; // Conteúdo à direita da label description?: string; // Descrição abaixo da label helper?: string; // Texto auxiliar abaixo do campo tooltip?: string; // Tooltip no ícone de info placeholder?: string; // Placeholder do input disabled?: boolean; // Desabilita o campo required?: boolean; // Marca como obrigatório (auto-detectado do schema) hideErrorMessage?: boolean; // Esconde mensagem de erro mask?: Mask; // Override de máscara formatter?: (v) => string; // Override de formatter }; ``` ### Estrutura Visual [Seção intitulada “Estrutura Visual”](#estrutura-visual) ```plaintext ┌──────────────────────────────────────────────┐ │ Label * [labelAddon]│ │ Descrição em texto menor │ ├──────────────────────────────────────────────┤ │ ┌──────────────────────────────────────────┐ │ │ │ Input/Select/etc │ │ │ └──────────────────────────────────────────┘ │ │ Helper text em cinza │ │ Mensagem de erro │ └──────────────────────────────────────────────┘ ``` *** ## useWatch [Seção intitulada “useWatch”](#usewatch) Hook para observar valores de campos de forma otimizada. **Localização:** `react-hook-form` ### Quando Usar [Seção intitulada “Quando Usar”](#quando-usar) | Cenário | Solução | | ----------------------------------------- | ----------------------- | | Renderização condicional baseada em valor | `useWatch` | | Side effects em useEffect | `form.watch()` com deps | | Validação dependente de outro campo | Schema Zod com refine | ### Sintaxe: Campo Único [Seção intitulada “Sintaxe: Campo Único”](#sintaxe-campo-único) ```typescript import { useWatch } from "react-hook-form"; const email = useWatch({ control: form.control, name: "email", }); ``` ### Sintaxe: Múltiplos Campos [Seção intitulada “Sintaxe: Múltiplos Campos”](#sintaxe-múltiplos-campos) ```typescript const [name, email] = useWatch({ control: form.control, name: ["name", "email"], }); ``` ### Exemplo: Renderização Condicional [Seção intitulada “Exemplo: Renderização Condicional”](#exemplo-renderização-condicional) ```tsx function PaymentForm() { const form = useForm({ schema: PaymentSchema }); const paymentMethod = useWatch({ control: form.control, name: "payment_method", }); return (
{paymentMethod === "credit_card" && ( <> )} {paymentMethod === "pix" && ( )} ); } ``` *** ## useFieldArray [Seção intitulada “useFieldArray”](#usefieldarray) Hook para gerenciar arrays dinâmicos de campos. **Localização:** `react-hook-form` ### API [Seção intitulada “API”](#api) | Propriedade | Tipo | Descrição | | --------------------- | ---------------------- | ---------------------------- | | `fields` | `Array<{id, ...data}>` | Items com ID único | | `append(data)` | `Function` | Adiciona item ao final | | `prepend(data)` | `Function` | Adiciona item no início | | `remove(index)` | `Function` | Remove item por índice | | `insert(index, data)` | `Function` | Insere em posição específica | | `swap(from, to)` | `Function` | Troca posição de items | | `move(from, to)` | `Function` | Move item para posição | | `replace(data[])` | `Function` | Substitui todo o array | ### Setup Básico [Seção intitulada “Setup Básico”](#setup-básico) ```tsx import { useFieldArray } from "react-hook-form"; const InviteSchema = z.object({ invites: z .array( z.object({ email: z.email("E-mail inválido"), }), ) .max(10, "Máximo 10 convites"), }); function InviteForm() { const form = useForm({ schema: InviteSchema }); const { fields, append, remove } = useFieldArray({ control: form.control, name: "invites", }); return (
{fields.map((field, index) => (
))}
); } ``` *** ## Validação com Zod [Seção intitulada “Validação com Zod”](#validação-com-zod) ### Schema Simples [Seção intitulada “Schema Simples”](#schema-simples) ```typescript import { z } from "zod"; const ProfileSchema = z.object({ name: z.string().min(1, "Obrigatório").max(100, "Máximo 100 caracteres"), email: z.email("E-mail inválido"), age: z.number().min(18, "Deve ser maior de idade").optional(), }); type ProfileForm = z.infer; ``` ### Custom Validators [Seção intitulada “Custom Validators”](#custom-validators) ```typescript import { isValidPhoneNumber } from "libphonenumber-js"; const ContactSchema = z.object({ whatsapp: z.custom( (value) => isValidPhoneNumber((value as string) || ""), { error: "Número de WhatsApp inválido" }, ), }); ``` ### Schema Composition [Seção intitulada “Schema Composition”](#schema-composition) Use `.safeExtend()` para estender schemas existentes: ```typescript import { UserSchema } from "@/services/user/types"; export const UserFormSchema = UserSchema.safeExtend({ confirmPassword: z.string(), terms: z.boolean().refine((v) => v, "Aceite os termos"), }); ``` ### Validação Condicional [Seção intitulada “Validação Condicional”](#validação-condicional) ```typescript const PaymentSchema = z .object({ method: z.enum(["pix", "card"]), pixKey: z.string().optional(), cardNumber: z.string().optional(), }) .refine( (data) => { if (data.method === "pix") return !!data.pixKey; if (data.method === "card") return !!data.cardNumber; return true; }, { message: "Preencha os dados do pagamento", path: ["method"] }, ); ``` *** ## Padrões Avançados [Seção intitulada “Padrões Avançados”](#padrões-avançados) ### withController HOC [Seção intitulada “withController HOC”](#withcontroller-hoc) O HOC `withController` encapsula fields com Controller automaticamente. ```tsx // @/components/fields/utils/with-controller.tsx import { withController } from "@/components/fields/utils/with-controller"; export const MyCustomField = withController( ({ value, onChange, disabled, ...props }) => { return ( onChange(newValue)} disabled={disabled} {...props} /> ); }, ); // Uso ; ``` ### ListField (Alternativa ao useFieldArray) [Seção intitulada “ListField (Alternativa ao useFieldArray)”](#listfield-alternativa-ao-usefieldarray) O componente `ListField` é uma alternativa declarativa ao useFieldArray. ```tsx // @/components/fields/list.tsx import { ListField } from "@/components/fields/list"; name="messages" label="Mensagens" control={form.control} onItemData={() => ({ text: "" })} onRenderItem={(item, index, control) => ( )} minItems={1} maxItems={5} addButtonPosition="bottom" />; ``` ### Form State Management [Seção intitulada “Form State Management”](#form-state-management) ```typescript // Verificar se form foi modificado const canSubmit = form.formState.isDirty; // Loading state const isSubmitting = form.formState.isSubmitting; // Reset após submit const onSubmit = async (data) => { await saveData(data); form.reset(data); }; ``` ### setValue com shouldDirty [Seção intitulada “setValue com shouldDirty”](#setvalue-com-shoulddirty) Ao usar `setValue` programaticamente, sempre passe `shouldDirty`: ```typescript // ✅ Correto form.setValue("field", newValue, { shouldDirty: true }); // ❌ Errado - não marca form como modificado form.setValue("field", newValue); ``` *** ## Anti-patterns [Seção intitulada “Anti-patterns”](#anti-patterns) ### Usar form.watch() no Render [Seção intitulada “Usar form.watch() no Render”](#usar-formwatch-no-render) ```typescript // ❌ ERRADO - causa re-renders desnecessários const value = form.watch("field"); // ✅ CORRETO - re-render apenas quando 'field' muda const value = useWatch({ control: form.control, name: "field" }); ``` ### Esquecer defaultValues [Seção intitulada “Esquecer defaultValues”](#esquecer-defaultvalues) ```typescript // ❌ ERRADO - valores undefined podem causar problemas const form = useForm({ schema: MySchema }); // ✅ CORRETO const form = useForm({ schema: MySchema, defaultValues: { name: "", items: [] }, }); ``` ### Validar Manualmente [Seção intitulada “Validar Manualmente”](#validar-manualmente) ```typescript // ❌ ERRADO - validação manual duplicada const onSubmit = (data) => { if (!data.email.includes("@")) { alert("E-mail inválido"); return; } }; // ✅ CORRETO - validação no schema const Schema = z.object({ email: z.email("E-mail inválido"), }); ``` ### Key com Index em Arrays Dinâmicos [Seção intitulada “Key com Index em Arrays Dinâmicos”](#key-com-index-em-arrays-dinâmicos) ```typescript // ❌ ERRADO - pode causar bugs de state {fields.map((field, index) => (
...
))} // ✅ CORRETO - usar field.id {fields.map((field, index) => (
...
))} ``` *** ## Referências [Seção intitulada “Referências”](#referências) * **react-hook-form:** * **Zod:** # Stack > Stack, convenções e templates do monorepo ## Core [Seção intitulada “Core”](#core) * **React 19.1** + **TypeScript 5.9** * **Vite** (não Webpack/Create React App) * **Node >= 22.3** (mínimo obrigatório) ## Estilo e Componentes [Seção intitulada “Estilo e Componentes”](#estilo-e-componentes) * **TailwindCSS 4** - todos os estilos devem usar Tailwind * **Radix UI** - componentes base headless * **shadcn/ui** - componentes pré-construídos * **Lucide React** - ícones (não use outros pacotes de ícones) * **Framer Motion** - animações ## State e Data Fetching [Seção intitulada “State e Data Fetching”](#state-e-data-fetching) * **Zustand** - state management global * **SEMPRE** use `devtools` middleware * **SEMPRE** use `shallow` ao selecionar múltiplos valores * **SEMPRE** versione a store com `version` * **TanStack Query (@tanstack/react-query)** - data fetching e cache * **React Hook Form** + **Zod** - formulários e validação ## Roteamento e Navegação [Seção intitulada “Roteamento e Navegação”](#roteamento-e-navegação) * **React Router 7** - roteamento ## Validações [Seção intitulada “Validações”](#validações) * **Zod 4** - validação e parsing de dados ## Utilidades [Seção intitulada “Utilidades”](#utilidades) * **class-variance-authority (cva)** - variantes de componentes * **clsx** + **tailwind-merge** - merge de classes CSS * **date-fns** - manipulação de datas (não use moment.js ou dayjs) ## Testing [Seção intitulada “Testing”](#testing) * **Vitest** (não Jest) * **Testing Library** (@testing-library/react) * **Playwright** (Testes e2e) * **MSW** - mock de API ## Code Quality [Seção intitulada “Code Quality”](#code-quality) * **Biome** - linting e formatting (NÃO use ESLint ou Prettier) *** ## Estrutura do Monorepo [Seção intitulada “Estrutura do Monorepo”](#estrutura-do-monorepo) ```cmd gaio-frontend/ ├── packages/ │ ├── common/ # Código compartilhado, utils, types, hooks │ ├── components/ # Biblioteca de componentes UI base │ ├── console/ # Aplicação principal (console) │ └── site/ # Site institucional ├── local.env # Variáveis de ambiente local (NÃO commitado) ├── deploy.env # Config de deploy (NÃO commitado) └── package.json # Root package.json (workspaces) ``` ## Workspaces npm [Seção intitulada “Workspaces npm”](#workspaces-npm) O projeto usa **npm workspaces** para gerenciar o monorepo. Cada package tem seu próprio `package.json`. ## Importações entre pacotes [Seção intitulada “Importações entre pacotes”](#importações-entre-pacotes) * Use `@gaio/common` para importar do common (hooks, utils, types) * Use `@gaio/components` para importar componentes base * Nunca use caminhos relativos entre pacotes (`../../packages/common`) * Imports locais dentro do mesmo package podem usar caminhos relativos ou aliases `@/` ## Dependências entre packages [Seção intitulada “Dependências entre packages”](#dependências-entre-packages) ```cmd console (aplicação principal) ├── depende de → @gaio/common ├── depende de → @gaio/components └── usa → radix-ui, react-query, zustand, etc. components (biblioteca UI base) ├── depende de → @gaio/common └── usa → radix-ui, lucide-react, framer-motion common (base compartilhada) └── hooks, utils, types, theme ``` *** ## Estrutura do Console (Principal) [Seção intitulada “Estrutura do Console (Principal)”](#estrutura-do-console-principal) ### Organização de pastas em packages/console/src/ [Seção intitulada “Organização de pastas em packages/console/src/”](#organização-de-pastas-em-packagesconsolesrc) ```cmd src/ ├── components/ # Componentes reutilizáveis por categoria │ ├── actions/ # Componentes de ações (delete, edit, settings) │ ├── animations/ # Componentes animados │ ├── buttons/ # Componentes de botões │ ├── cards/ # Componentes de cards │ ├── chart/ # Componentes de gráficos │ ├── contact/ # Componentes relacionados a contatos │ ├── custom-fields/ # Componentes de campos customizados │ ├── dates/ # Componentes relacionados a datas │ ├── dynamic-filter/ # Sistema de filtros dinâmicos │ ├── fields/ # Componentes de campos de formulário │ ├── header/ # Componentes do header │ ├── modal/ # Componentes de modais │ ├── screens/ # Componentes de layouts e sidebar │ ├── sentiment/ # Componentes de análise de sentimento │ ├── tags/ # Componentes de tags │ ├── ui/ # Componentes UI genéricos │ └── utils/ # Componentes utilitários ├── features/ # Features complexas com domínio próprio │ ├── chat/ # Feature de chat completa │ │ ├── components/ │ │ ├── message/ │ │ ├── store/ # Store Zustand específica do chat │ │ ├── types/ │ │ └── utils/ │ └── contacts/ # Feature de contatos completa │ └── editor/ ├── screens/ # Telas/páginas da aplicação │ ├── admin/ # Telas administrativas │ ├── user/ # Telas de usuário │ └── error/ # Telas de erro ├── hooks/ # Custom hooks globais ├── services/ # Serviços de API ├── utils/ # Utilitários gerais ├── config/ # Configurações ├── constants/ # Constantes └── router/ # Configuração de rotas ``` ### Nomenclatura de arquivos [Seção intitulada “Nomenclatura de arquivos”](#nomenclatura-de-arquivos) Baseado na estrutura real do projeto 1. **Componentes em `src/components/`**: * Pasta: `kebab-case/` * Arquivo: `component.tsx` * Exemplo: `src/components/alert/component.tsx` * Função exportada: `PascalCase` (ex: `export function Alert()`) 2. **Componentes em `src/features/`**: * Pasta: `kebab-case/` * Arquivos específicos: `kebab-case.tsx` * Exemplo: `src/features/chat/chat-header.tsx` * Função exportada: `PascalCase` (ex: `export function ChatHeader()`) 3. **Outros arquivos**: * Hooks: `use-kebab-case.ts` (ex: `use-auth.ts`) * Services: `kebab-case.ts` (ex: `user.ts` em `services/`) * Utils: `kebab-case.ts` (ex: `format-date.ts`) * Types: `kebab-case.ts` ou `kebab-case.types.ts` * Stores: `kebab-case.ts` (ex: `chat.ts` em `features/chat/store/`) *** ## Padrões de Componentes React [Seção intitulada “Padrões de Componentes React”](#padrões-de-componentes-react) ### Template para Componente Base (src/components/) [Seção intitulada “Template para Componente Base (src/components/)”](#template-para-componente-base-srccomponents) src/components/button/component.tsx ```tsx import { type ComponentProps } from "react"; import { cva, type VariantProps } from "class-variance-authority"; import { cn } from "@gaio/components"; const buttonVariants = cva("base-classes-here", { variants: { variant: { default: "variant-classes", outline: "variant-classes", }, size: { default: "size-classes", sm: "size-classes", }, }, defaultVariants: { variant: "default", size: "default", }, }); export interface ButtonProps extends ComponentProps<"button">, VariantProps { // Props específicas aqui } export function Button({ className, variant, size, ...props }: ButtonProps) { return ( ); expect(screen.getByText('Click me')).toBeInTheDocument(); }); }); ``` ### Regras de Testes [Seção intitulada “Regras de Testes”](#regras-de-testes) * Use **Vitest** (não Jest) * Arquivo: `[nome].test.tsx` ao lado do componente * Use Testing Library queries semânticas *** ## TypeScript Avançado - Melhores Práticas [Seção intitulada “TypeScript Avançado - Melhores Práticas”](#typescript-avançado---melhores-práticas) ### Regras Fundamentais [Seção intitulada “Regras Fundamentais”](#regras-fundamentais) 1. **Sempre use tipagem explícita** - evite `any` a todo custo 2. **Prefira tipos estreitos** - use union types ao invés de genéricos demais 3. **Use type guards** - valide tipos em runtime 4. **Imutabilidade** - prefira `readonly` e `const` 5. **Composição sobre herança** - use tipos compostos *** ## Tipagem de Funções [Seção intitulada “Tipagem de Funções”](#tipagem-de-funções) ### Regras de Inferência [Seção intitulada “Regras de Inferência”](#regras-de-inferência) 1. **Funções exportadas/públicas**: SEMPRE tipagem explícita de retorno 2. **Funções internas/privadas simples**: Inferência OK se óbvio 3. **Funções genéricas/complexas**: SEMPRE tipagem explícita 4. **Parâmetros**: SEMPRE tipados (nunca inferir) ### ✅ Bom - Tipagem apropriada [Seção intitulada “✅ Bom - Tipagem apropriada”](#-bom---tipagem-apropriada) ```typescript // ✅ Função exportada - tipo de retorno explícito export function calculateTotal(items: CartItem[], discount: number): number { return items.reduce((sum, item) => sum + item.price, 0) * (1 - discount); } // ✅ Função interna simples - inferência OK function sumPrices(items: CartItem[]) { return items.reduce((sum, item) => sum + item.price, 0); // inferido como number } // ✅ Arrow functions exportadas - tipo explícito export const formatUser = (user: User): FormattedUser => ({ id: user.id, fullName: `${user.firstName} ${user.lastName}`, email: user.email.toLowerCase(), }); // ✅ Arrow function interna - inferência OK se simples const normalizeEmail = (email: string) => email.toLowerCase().trim(); // ✅ Async exportada - tipo explícito export async function fetchUser(id: string): Promise { const response = await api.get(`/users/${id}`); return response.data; } // ✅ Callback/handler interno - inferência OK const handleClick = (e: React.MouseEvent) => { Logger.debug(e.currentTarget.value); // inferido como void }; // ✅ Função genérica - SEMPRE explícita function mapArray(items: T[], mapper: (item: T) => U): U[] { return items.map(mapper); } ``` ### ❌ Ruim - Tipagem inadequada [Seção intitulada “❌ Ruim - Tipagem inadequada”](#-ruim---tipagem-inadequada) ```typescript // ❌ NUNCA use any function processData(data: any) { return data.map((item: any) => item.value); } // ❌ Parâmetros sem tipo function calculate(a, b) { return a + b; } // ❌ Função exportada sem tipo de retorno export function getUser(id: string) { return api.get(`/users/${id}`); // Que tipo retorna? } // ❌ Função complexa sem tipo de retorno function complexOperation(data: Data[]) { if (data.length === 0) return null; if (data.length === 1) return data[0]; return data; // null | Data | Data[] - ambíguo! } ``` ### Quando usar tipagem explícita de retorno [Seção intitulada “Quando usar tipagem explícita de retorno”](#quando-usar-tipagem-explícita-de-retorno) **SEMPRE tipagem explícita:** * ✅ Funções exportadas (API pública) * ✅ Funções assíncronas * ✅ Funções com múltiplos return types * ✅ Funções genéricas * ✅ Callbacks complexos * ✅ Métodos de classe públicos **Inferência OK:** * ✅ Funções auxiliares internas simples * ✅ Arrow functions em componentes (onClick, onChange) * ✅ Transformações simples de dados * ✅ Guards de tipo óbvios *** ## Error Handling - Padrão Obrigatório [Seção intitulada “Error Handling - Padrão Obrigatório”](#error-handling---padrão-obrigatório) ### Regras de Error Handling [Seção intitulada “Regras de Error Handling”](#regras-de-error-handling) 1. **SEMPRE use try-catch em operações assíncronas** 2. **SEMPRE tipagem de erro específica** 3. **NUNCA deixe catch vazio** 4. **SEMPRE logar erros apropriadamente** 5. **Retorne tipos explícitos de sucesso/erro** ### ✅ Pattern: Result Type (Recomendado) [Seção intitulada “✅ Pattern: Result Type (Recomendado)”](#-pattern-result-type-recomendado) ```typescript // Type helper para resultado de operações type Result = | { success: true; data: T } | { success: false; error: E }; // Uso em serviços async function fetchUserSafe(id: string): Promise> { try { const response = await api.get(`/users/${id}`); return { success: true, data: response.data }; } catch (error) { if (error instanceof ApiError) { return { success: false, error: new Error(`Failed to fetch user: ${error.message}`), }; } return { success: false, error: new Error("Unknown error occurred"), }; } } // Uso no componente async function loadUser(id: string) { const result = await fetchUserSafe(id); if (result.success) { setUser(result.data); } else { showError(result.error.message); } } ``` ### ✅ Pattern: Try-Catch com Tipagem [Seção intitulada “✅ Pattern: Try-Catch com Tipagem”](#-pattern-try-catch-com-tipagem) ```typescript // Sempre tipagem específica de erro class ApiError extends Error { constructor( message: string, public statusCode: number, public code?: string, ) { super(message); this.name = "ApiError"; } } // Uso correto async function updateUser(id: string, data: UpdateUserData): Promise { try { const response = await api.patch(`/users/${id}`, data); return response.data; } catch (error) { // Type guard para erro conhecido if (error instanceof ApiError) { if (error.statusCode === 404) { throw new Error(`User ${id} not found`); } if (error.statusCode === 403) { throw new Error("Permission denied"); } } // Erro desconhecido if (error instanceof Error) { throw new Error(`Failed to update user: ${error.message}`); } // Fallback throw new Error("An unexpected error occurred"); } } ``` ### ✅ Pattern: Custom Error Types [Seção intitulada “✅ Pattern: Custom Error Types”](#-pattern-custom-error-types) ```typescript // Defina erros específicos do domínio class ValidationError extends Error { constructor( public field: string, message: string, ) { super(message); this.name = "ValidationError"; } } class NotFoundError extends Error { constructor( public resource: string, public id: string, ) { super(`${resource} with id ${id} not found`); this.name = "NotFoundError"; } } // Uso em funções async function deleteUser(id: string): Promise { try { await api.delete(`/users/${id}`); } catch (error) { if (error instanceof ApiError && error.statusCode === 404) { throw new NotFoundError("User", id); } throw error; } } // No componente try { await deleteUser(userId); showSuccess("User deleted"); } catch (error) { if (error instanceof NotFoundError) { showError(`User not found`); } else if (error instanceof ValidationError) { showError(`Invalid ${error.field}`); } else { showError("Failed to delete user"); } } ``` ### ❌ Ruim - Catch sem tipagem [Seção intitulada “❌ Ruim - Catch sem tipagem”](#-ruim---catch-sem-tipagem) ```typescript // NUNCA faça isso try { await api.get("/users"); } catch (error) { Logger.error(error); // error é any } // NUNCA catch vazio try { await api.post("/users", data); } catch (e) { // Silenciar erro é perigoso } // NUNCA capture tudo sem especificar try { const result = await complexOperation(); } catch { return null; // Perde informação do erro } ``` *** ## Type Guards e Narrowing [Seção intitulada “Type Guards e Narrowing”](#type-guards-e-narrowing) ### ✅ Type Guards Customizados [Seção intitulada “✅ Type Guards Customizados”](#-type-guards-customizados) ```typescript // Type guard com is function isUser(value: unknown): value is User { return ( typeof value === "object" && value !== null && "id" in value && "email" in value && typeof value.id === "string" && typeof value.email === "string" ); } // Uso function processResponse(data: unknown) { if (isUser(data)) { // TypeScript sabe que data é User aqui Logger.debug(data.email); } } // Type guard para arrays function isUserArray(value: unknown): value is User[] { return Array.isArray(value) && value.every(isUser); } ``` ### ✅ Discriminated Unions [Seção intitulada “✅ Discriminated Unions”](#-discriminated-unions) ```typescript // Union com discriminador type ApiResponse = | { status: 'loading' } | { status: 'error'; error: Error } | { status: 'success'; data: T }; // Uso com exhaustive checking function handleResponse(response: ApiResponse) { switch (response.status) { case 'loading': return ; case 'error': return ; case 'success': return ; default: // Exhaustive check - não compila se faltar um case const _exhaustive: never = response; return _exhaustive; } } ``` *** ## Utility Types e Types Avançados [Seção intitulada “Utility Types e Types Avançados”](#utility-types-e-types-avançados) ### ✅ Utility Types Nativos [Seção intitulada “✅ Utility Types Nativos”](#-utility-types-nativos) ```typescript // Partial - todos campos opcionais type UpdateUser = Partial; // Required - todos campos obrigatórios type CompleteUser = Required; // Pick - selecionar campos type UserPreview = Pick; // Omit - excluir campos type UserWithoutPassword = Omit; // Record - objeto com chaves tipadas type UserMap = Record; // Readonly - imutável type ImmutableUser = Readonly; ``` ### ✅ Types Customizados Avançados [Seção intitulada “✅ Types Customizados Avançados”](#-types-customizados-avançados) ```typescript // Extrair tipo de retorno de função type UserServiceReturn = ReturnType; // Extrair tipo de parâmetros type UserServiceParams = Parameters; // Extrair tipo de Promise type UnwrapPromise = T extends Promise ? U : T; type UserData = UnwrapPromise>; // Non-nullable type NonNullableUser = NonNullable; // Array element type type ArrayElement = T extends (infer E)[] ? E : never; type UserFromArray = ArrayElement; ``` ### ✅ Conditional Types [Seção intitulada “✅ Conditional Types”](#-conditional-types) ```typescript // Tipo condicional type IsString = T extends string ? true : false; // Extrair campos de um tipo específico type StringKeys = { [K in keyof T]: T[K] extends string ? K : never; }[keyof T]; type UserStringFields = StringKeys; // 'name' | 'email' | ... // Deep Readonly type DeepReadonly = { readonly [K in keyof T]: T[K] extends object ? DeepReadonly : T[K]; }; ``` *** ## Imutabilidade e Readonly [Seção intitulada “Imutabilidade e Readonly”](#imutabilidade-e-readonly) ### ✅ Prefira Imutabilidade [Seção intitulada “✅ Prefira Imutabilidade”](#-prefira-imutabilidade) ```typescript // Use readonly em interfaces interface User { readonly id: string; readonly email: string; name: string; // Apenas campos que mudam ficam mutáveis } // Use const assertions const CONFIG = { API_URL: "https://api.example.com", TIMEOUT: 5000, } as const; // Agora CONFIG é deeply readonly type Config = typeof CONFIG; // { readonly API_URL: "https://api.example.com", ... } // Arrays imutáveis const ROLES = ["admin", "user", "guest"] as const; type Role = (typeof ROLES)[number]; // 'admin' | 'user' | 'guest' // Readonly em parâmetros function processUsers(users: readonly User[]): void { // users.push(...) // Erro! Não pode modificar const newUsers = [...users, newUser]; // Ok! Cria novo array } ``` *** ## Patterns de Tipagem em React [Seção intitulada “Patterns de Tipagem em React”](#patterns-de-tipagem-em-react) ### ✅ Props com Genéricos [Seção intitulada “✅ Props com Genéricos”](#-props-com-genéricos) ```typescript // Componente genérico interface ListProps { items: T[]; renderItem: (item: T) => React.ReactNode; keyExtractor: (item: T) => string; } function List({ items, renderItem, keyExtractor }: ListProps) { return (
{items.map((item) => (
{renderItem(item)}
))}
); } // Uso items={users} renderItem={(user) => } keyExtractor={(user) => user.id} /> ``` ### ✅ Event Handlers Tipados [Seção intitulada “✅ Event Handlers Tipados”](#-event-handlers-tipados) ```typescript // Handlers específicos type ButtonClickHandler = React.MouseEvent; type InputChangeHandler = React.ChangeEvent; type FormSubmitHandler = React.FormEvent; // Componente interface FormProps { onSubmit: (data: FormData) => void; } function Form({ onSubmit }: FormProps) { const handleSubmit = (e: FormSubmitHandler) => { const formData = new FormData(e.currentTarget); onSubmit(Object.fromEntries(formData)); }; return
...
; } ``` ### ✅ Children Tipados [Seção intitulada “✅ Children Tipados”](#-children-tipados) ```typescript // Children específico interface CardProps { children: React.ReactNode; header?: React.ReactElement; } // Function as children interface RenderProps { data: T; children: (data: T) => React.ReactNode; } function DataProvider({ data, children }: RenderProps) { return <>{children(data)}; } ``` *** ## Checklist TypeScript [Seção intitulada “Checklist TypeScript”](#checklist-typescript) Antes de commitar código: * [ ] Nenhum uso de `any` (exceto casos extremos justificados) * [ ] Todos os tipos exportados estão documentados * [ ] Try-catch em todas operações assíncronas * [ ] Erros tipados adequadamente * [ ] Type guards onde necessário * [ ] Uso de utility types quando apropriado * [ ] Interfaces prefiram `readonly` onde aplicável * [ ] Discriminated unions para estados complexos * [ ] Exhaustive checking em switches * [ ] Props de componentes totalmente tipadas *** ## Erros Comuns a Evitar [Seção intitulada “Erros Comuns a Evitar”](#erros-comuns-a-evitar) ### ❌ NUNCA faça [Seção intitulada “❌ NUNCA faça”](#-nunca-faça) ```typescript // 1. Usar any const data: any = fetchData(); // 2. Type assertion desnecessário const user = data as User; // Perigoso se data não for User // 3. Non-null assertion sem garantia const value = possiblyNull!; // Pode quebrar em runtime // 4. Ignorar erros try { await operation(); } catch {} // Silenciar erro // 5. Tipos muito amplos function process(data: object) {} // object é muito genérico // 6. Mutação de readonly function modify(arr: readonly string[]) { arr.push("new"); // Erro! } ``` ### ✅ SEMPRE faça [Seção intitulada “✅ SEMPRE faça”](#-sempre-faça) ```typescript // 1. Type guards if (isUser(data)) { Logger.debug(data.email); } // 2. Validação adequada const user = validateUser(data); // Lança erro se inválido // 3. Null checking const value = possiblyNull ?? defaultValue; // 4. Log apropriado try { await operation(); } catch (error) { logger.error("Operation failed", { error }); throw error; } // 5. Tipos específicos interface ProcessData { id: string; value: number; } function process(data: ProcessData) {} // 6. Criar nova array function modify(arr: readonly string[]): string[] { return [...arr, "new"]; } ``` *** ## Scripts e Comandos [Seção intitulada “Scripts e Comandos”](#scripts-e-comandos) ### Scripts (root) [Seção intitulada “Scripts (root)”](#scripts-root) ```bash npm run build # Build todos workspaces npm run lint # Biome lint + typecheck npm run test # Rodar testes npm run serve:demo # Dev apontando para stg ``` ### Ambientes [Seção intitulada “Ambientes”](#ambientes) * **local** - desenvolvimento local * **test/dev** - ambiente de desenvolvimento * **demo/stg** - staging * **live/prd** - produção *** ## EZ4 - Infrastructure as Code [Seção intitulada “EZ4 - Infrastructure as Code”](#ez4---infrastructure-as-code) O projeto usa **[EZ4](https://github.com/sbalmt/ez4)** para deploy na AWS. ### Pacotes EZ4 usados [Seção intitulada “Pacotes EZ4 usados”](#pacotes-ez4-usados) * `@ez4/common` - Tipos e utilitários * `@ez4/utils` - Funções gerais * `@ez4/project` - Config de projeto * `@ez4/aws-bucket` - S3 Bucket * `@ez4/aws-cloudfront` - CloudFront (CDN) * `@ez4/distribution` - Contrato de distribuição *** ## ✅ Checklist Antes de Implementar [Seção intitulada “✅ Checklist Antes de Implementar”](#-checklist-antes-de-implementar) ### IMPORTANTE [Seção intitulada “IMPORTANTE”](#importante) Este documento contém diretrizes obrigatórias. Claude Code deve seguir TODOS os padrões. ### Antes de QUALQUER implementação [Seção intitulada “Antes de QUALQUER implementação”](#antes-de-qualquer-implementação) * [ ] Li arquivos relevantes na pasta? * [ ] Identifiquei componentes/código similar? * [ ] Identifiquei padrões de nomenclatura? * [ ] Identifiquei estrutura de código? * [ ] Mostrei exemplos do código existente? * [ ] Recebi confirmação? ### Durante implementação [Seção intitulada “Durante implementação”](#durante-implementação) * [ ] Seguindo EXATAMENTE os padrões identificados? * [ ] Reutilizando código ao invés de duplicar? * [ ] TypeScript com tipagem completa? * [ ] Usando Biome (não ESLint/Prettier)? * [ ] Usando TailwindCSS (não CSS modules)? * [ ] Usando bibliotecas corretas (Vitest, não Jest)? * [ ] Nomenclatura correta? * [ ] Componentes base: `[pasta]/component.tsx`? * [ ] Features: `[feature]/kebab-case.tsx`? * [ ] Stores com devtools + version? * [ ] Usando shallow em Zustand? *** ## O QUE NUNCA FAZER [Seção intitulada “O QUE NUNCA FAZER”](#o-que-nunca-fazer) * ❌ Nunca use ESLint ou Prettier (use Biome) * ❌ Nunca use Jest (use Vitest) * ❌ Nunca use CSS modules (use Tailwind) * ❌ Nunca use moment.js/dayjs (use date-fns) * ❌ Nunca implemente sem analisar código existente * ❌ Nunca duplique código que já existe * ❌ Nunca crie padrões novos sem justificativa * ❌ Nunca use Context API (use Zustand) * ❌ Nunca faça fetch direto (use React Query) * ❌ Nunca commite código sem tipagem TypeScript * ❌ Nunca commite secrets/API keys * ❌ Nunca crie store Zustand sem devtools + version * ❌ Nunca use múltiplos valores Zustand sem shallow * ❌ Nunca ignore a estrutura de pastas do projeto *** ## Perguntas Frequentes [Seção intitulada “Perguntas Frequentes”](#perguntas-frequentes) **P: Onde criar componentes reutilizáveis?** R: Em `src/components/[categoria]/component.tsx` **P: Onde criar features complexas?** R: Em `src/features/[feature-name]/` **P: Como nomear arquivos de componentes?** R: Componentes base: `component.tsx`. Features: `kebab-case.tsx` **P: Onde ficam as stores Zustand?** R: Dentro das features: `src/features/[feature]/store/` **P: Como usar ícones?** R: Lucide React (já instalado) **P: Como estilizar?** R: TailwindCSS + cva para variantes *** ## Referências [Seção intitulada “Referências”](#referências) * [EZ4 GitHub](https://github.com/sbalmt/ez4) * [Radix UI](https://www.radix-ui.com/) * [shadcn/ui](https://ui.shadcn.com/) * [TanStack Query](https://tanstack.com/query/latest) * [Zustand](https://docs.pmnd.rs/zustand) * [Biome](https://biomejs.dev/) * [Vitest](https://vitest.dev/) * [Doppler](https://www.doppler.com/) * [TypeScript Handbook](https://www.typescriptlang.org/docs/handbook/intro.html) * [TypeScript Deep Dive](https://basarat.gitbook.io/typescript/) * [Type Challenges](https://github.com/type-challenges/type-challenges) * [Total TypeScript](https://www.totaltypescript.com/) # Internacionalização (i18n) > Guia completo para implementação de internacionalização na plataforma Gaio. * [Introdução](#introdu%C3%A7%C3%A3o) * [Arquitetura](#arquitetura) * [Hooks e Funções](#hooks-e-fun%C3%A7%C3%B5es) * [Padrões de Tradução](#padr%C3%B5es-de-tradu%C3%A7%C3%A3o) * [Namespaces](#namespaces) * [Integração com Zod](#integra%C3%A7%C3%A3o-com-zod) * [Pluralização e Interpolação](#pluraliza%C3%A7%C3%A3o-e-interpola%C3%A7%C3%A3o) * [Melhores Práticas de Interpolação](#melhores-pr%C3%A1ticas-de-interpola%C3%A7%C3%A3o) * [Formatação HTML em Traduções](#formata%C3%A7%C3%A3o-html-em-tradu%C3%A7%C3%B5es) * [Caching com localStorage](#caching-com-localstorage) * [Seletor de Idioma](#seletor-de-idioma) * [CLI e Extração](#cli-e-extra%C3%A7%C3%A3o) * [Validação i18n (CI/CD)](#valida%C3%A7%C3%A3o-i18n-cicd) * [Testes](#testes) * [Anti-patterns](#anti-patterns) * [Tipagem TypeScript (Intellisense)](#tipagem-typescript-intellisense) *** ## Introdução [Seção intitulada “Introdução”](#introdução) ### Stack [Seção intitulada “Stack”](#stack) | Biblioteca | Versão | Propósito | | ---------------------------------- | ------ | --------------------------- | | `i18next` | 25.x | Core de internacionalização | | `react-i18next` | 16.x | Bindings React | | `i18next-browser-languagedetector` | 8.x | Detecção automática | | `i18next-chained-backend` | 5.x | Múltiplos backends | | `i18next-localstorage-backend` | 4.x | Cache em localStorage | | `i18next-resources-to-backend` | 1.x | Converter recursos inline | | `i18next-cli` | 1.x | Extração de strings (CLI) | ### Idiomas Suportados [Seção intitulada “Idiomas Suportados”](#idiomas-suportados) | Código | Nome | Status | | ------- | ------------------ | -------- | | `pt-BR` | Português (Brasil) | Fonte | | `en` | English | Tradução | ### Arquitetura [Seção intitulada “Arquitetura”](#arquitetura) ```plaintext ┌─────────────────────────────────────────────────────────────┐ │ @gaio/i18n │ │ (Package dedicado para internacionalização) │ └─────────────────────────┬───────────────────────────────────┘ │ ┌───────────────┼───────────────┐ ▼ ▼ ▼ ┌─────────────────┐ ┌───────────┐ ┌───────────────┐ │ I18nProvider │ │ useT │ │ zodMessages │ │ (Wrapper App) │ │ (Hook) │ │ (Validação) │ └─────────────────┘ └───────────┘ └───────────────┘ │ ▼ ┌─────────────────────────────────────────────────────────────┐ │ locales/ │ │ pt-BR/common.json │ en/common.json │ ... │ └─────────────────────────────────────────────────────────────┘ ``` ### Imports Padrão [Seção intitulada “Imports Padrão”](#imports-padrão) O package é dividido em dois módulos: ```typescript // ============================================ // @gaio/i18n/core - SEM dependência de React // Use em: utils, services, schemas Zod, constantes // ============================================ import { i18n, zodMessages, SUPPORTED_LOCALES, LOCALE_LABELS, clearI18nCache, getCacheVersion, } from "@gaio/i18n/core"; // ============================================ // @gaio/i18n/react - COM dependência de React // Use em: componentes, hooks, providers // ============================================ import { I18nProvider, useT, useLocale, changeLocale } from "@gaio/i18n/react"; // Componente Trans (re-export do react-i18next) import { Trans } from "@gaio/i18n/react"; ``` **Quando usar cada um:** | Módulo | Uso | Exemplos | | ------------------ | ------------------ | ---------------------------------------- | | `@gaio/i18n/core` | Arquivos sem React | Schemas Zod, utils, services, constantes | | `@gaio/i18n/react` | Componentes React | Hooks, Provider, componentes UI | *** ## Arquitetura [Seção intitulada “Arquitetura”](#arquitetura-1) ### Estrutura do Package [Seção intitulada “Estrutura do Package”](#estrutura-do-package) ```plaintext packages/i18n/ ├── package.json ├── tsconfig.json ├── i18next.config.ts └── src/ ├── core/ # @gaio/i18n/core (sem React) │ ├── index.ts # Exports: i18n, zodMessages, constantes, cache utils │ ├── constants.ts # SUPPORTED_LOCALES, NAMESPACES │ ├── config.ts # Setup i18next com ChainedBackend │ └── validation.ts # zodMessages │ ├── react/ # @gaio/i18n/react (com React) │ ├── index.ts # Exports: Provider, hooks, Trans │ ├── provider.tsx # I18nProvider │ └── hooks.ts # useT, useLocale, changeLocale │ ├── types/ # Tipagem TypeScript │ └── resources.d.ts # Declaração para intellisense │ └── locales/ # Traduções ├── pt-BR/ │ ├── index.ts │ ├── common.json │ ├── validation.json │ ├── errors.json │ ├── auth.json │ ├── inbox.json │ ├── workflows.json │ └── settings.json └── en/ └── ... (mesmos arquivos) ``` ### Exports do Package [Seção intitulada “Exports do Package”](#exports-do-package) package.json ```json { "exports": { "./core": "./src/core/index.ts", "./react": "./src/react/index.ts" } } ``` ### Integração com Console [Seção intitulada “Integração com Console”](#integração-com-console) O `I18nProvider` deve ser o provider mais externo na aplicação: packages/console/src/providers.tsx ```tsx import { I18nProvider } from "@gaio/i18n/react"; export function AppProviders({ children }: { children: ReactNode }) { return ( {children} ); } ``` *** ## Hooks e Funções [Seção intitulada “Hooks e Funções”](#hooks-e-funções) ### useT [Seção intitulada “useT”](#uset) Hook principal para acessar traduções. Wrapper do `useTranslation` do react-i18next. ```typescript import { useT } from '@gaio/i18n/react'; function MyComponent() { const { t } = useT(); return ; } ``` #### Com Namespace Específico [Seção intitulada “Com Namespace Específico”](#com-namespace-específico) ```typescript // Namespace único const { t } = useT("validation"); t("required"); // validation:required // Múltiplos namespaces const { t } = useT(["common", "validation"]); t("common:actions.save"); t("validation:required"); ``` #### Retorno do useT [Seção intitulada “Retorno do useT”](#retorno-do-uset) | Propriedade | Tipo | Descrição | | ----------- | --------------------------- | -------------------------- | | `t` | `(key, options?) => string` | Função de tradução | | `i18n` | `i18n` | Instância i18next | | `ready` | `boolean` | Se traduções estão prontas | ### useLocale [Seção intitulada “useLocale”](#uselocale) Hook para obter o idioma atual. **Localização:** `@gaio/i18n/react` ```typescript import { useLocale } from '@gaio/i18n/react'; function LanguageIndicator() { const locale = useLocale(); // 'pt-BR' | 'en-US' return Idioma: {locale}; } ``` ### changeLocale [Seção intitulada “changeLocale”](#changelocale) Função para trocar o idioma programaticamente. **Localização:** `@gaio/i18n/react` ```typescript import { changeLocale } from "@gaio/i18n/react"; import type { SupportedLocale } from "@gaio/i18n/core"; function handleLanguageChange(locale: SupportedLocale) { changeLocale(locale); // Atualiza i18n + cookie } ``` *** ## Padrões de Tradução [Seção intitulada “Padrões de Tradução”](#padrões-de-tradução) ### Convenção de Keys [Seção intitulada “Convenção de Keys”](#convenção-de-keys) **IMPORTANTE:** Todas as keys devem estar em **inglês** usando `snake_case`: ```typescript // ✅ CORRETO - keys em inglês, descritivas t("validation:required"); t("validation:invalid_email"); t("common:actions.save"); t("common:messages.success.saved"); // ❌ ERRADO - keys em português t("validation:campo_obrigatorio"); t("validation:email_invalido"); t("common:acoes.salvar"); ``` ### Texto Simples [Seção intitulada “Texto Simples”](#texto-simples) ```tsx import { useT } from "@gaio/i18n/react"; function SaveButton() { const { t } = useT(); return ; } ``` locales/pt-BR/common.json ```json { "actions": { "save": "Salvar", "cancel": "Cancelar", "confirm": "Confirmar" } } ``` ### Com Interpolação [Seção intitulada “Com Interpolação”](#com-interpolação) ```tsx const { t } = useT(); // Simples t("welcome", { name: "João" }); // "Olá, João!" // Múltiplas variáveis t("summary", { count: 5, total: 100 }); // "5 de 100 itens" ``` ```json { "welcome": "Olá, {{name}}!", "summary": "{{count}} de {{total}} itens" } ``` ### JSX Complexo (Trans) [Seção intitulada “JSX Complexo (Trans)”](#jsx-complexo-trans) Use o componente `Trans` quando precisar de JSX dentro da tradução: ```tsx import { Trans } from "@gaio/i18n/react"; Ao continuar, você aceita os Termos de Uso ; ``` ```json { "terms": "Ao continuar, você aceita os <1>Termos de Uso" } ``` ### Toast Notifications [Seção intitulada “Toast Notifications”](#toast-notifications) ```tsx import { useT } from "@gaio/i18n/react"; import { toast } from "sonner"; function MyComponent() { const { t } = useT(); const handleSave = async () => { try { await saveData(); toast.success(t("common:messages.success.saved")); } catch { toast.error(t("common:messages.error.save")); } }; } ``` ### Fora de Componentes React [Seção intitulada “Fora de Componentes React”](#fora-de-componentes-react) Para usar traduções fora de componentes (utils, services), importe a instância `i18n` do módulo core: ```typescript import { i18n } from "@gaio/i18n/core"; export function formatError(code: string): string { return i18n.t(`errors:${code}`); } ``` *** ## Namespaces [Seção intitulada “Namespaces”](#namespaces) Os namespaces organizam traduções por domínio/feature: | Namespace | Conteúdo | Uso | | ------------ | ---------------------- | ------------------------------ | | `common` | Botões, labels, status | UI compartilhada | | `validation` | Mensagens de validação | Schemas Zod | | `errors` | Erros HTTP/API | Error handlers | | `auth` | Login, registro | `screens/auth/` | | `inbox` | Chat, mensagens | `screens/dashboard/inbox/` | | `workflows` | Editor de automação | `screens/dashboard/workflows/` | | `settings` | Configurações | `screens/dashboard/settings/` | ### Vocabulário Padrão [Seção intitulada “Vocabulário Padrão”](#vocabulário-padrão) O namespace `common` contém elementos reutilizáveis em toda a aplicação. **Sempre use essas keys ao invés de criar duplicatas.** #### Ações (Botões) [Seção intitulada “Ações (Botões)”](#ações-botões) common.json ```json { "actions": { "save": "Salvar", "cancel": "Cancelar", "confirm": "Confirmar", "create": "Criar", "edit": "Editar", "delete": "Excluir", "remove": "Remover", "add": "Adicionar", "send": "Enviar", "back": "Voltar", "next": "Próximo", "previous": "Anterior", "close": "Fechar", "open": "Abrir", "search": "Buscar", "filter": "Filtrar", "clear": "Limpar", "copy": "Copiar", "paste": "Colar", "duplicate": "Duplicar", "refresh": "Atualizar", "load_more": "Carregar mais", "view_more": "Ver mais", "view_less": "Ver menos", "expand": "Expandir", "collapse": "Recolher", "export": "Exportar", "import": "Importar", "download": "Baixar", "upload": "Enviar arquivo" } } ``` **Uso:** ```tsx t("common:actions.save"); // "Salvar" t("common:actions.cancel"); // "Cancelar" ``` #### Status [Seção intitulada “Status”](#status) common.json ```json { "status": { "active": "Ativo", "inactive": "Inativo", "pending": "Pendente", "approved": "Aprovado", "rejected": "Rejeitado", "completed": "Concluído", "in_progress": "Em andamento", "paused": "Pausado", "canceled": "Cancelado", "archived": "Arquivado", "draft": "Rascunho", "published": "Publicado", "scheduled": "Agendado", "expired": "Expirado", "error": "Erro", "success": "Sucesso", "loading": "Carregando...", "processing": "Processando..." } } ``` **Uso:** ```tsx t("common:status.active"); // "Ativo" t("common:status.pending"); // "Pendente" ``` #### Labels de Campos [Seção intitulada “Labels de Campos”](#labels-de-campos) common.json ```json { "fields": { "name": "Nome", "email": "E-mail", "phone": "Telefone", "password": "Senha", "confirm_password": "Confirmar senha", "description": "Descrição", "title": "Título", "date": "Data", "time": "Hora", "start_date": "Data de início", "end_date": "Data de término", "address": "Endereço", "city": "Cidade", "state": "Estado", "country": "País", "zip_code": "CEP", "cpf": "CPF", "cnpj": "CNPJ", "notes": "Observação", "attachment": "Anexo", "file": "Arquivo", "image": "Imagem", "link": "Link", "url": "URL", "quantity": "Quantidade", "value": "Valor", "price": "Preço", "total": "Total" } } ``` **Uso:** ```tsx t("common:fields.name"); // "Nome" t("common:fields.email"); // "E-mail" ``` #### Placeholders [Seção intitulada “Placeholders”](#placeholders) common.json ```json { "placeholders": { "search": "Buscar...", "select": "Selecione...", "type": "Digite...", "email": "exemplo@email.com", "phone": "(00) 00000-0000", "date": "DD/MM/AAAA" } } ``` **Uso:** ```tsx ``` #### Mensagens Genéricas [Seção intitulada “Mensagens Genéricas”](#mensagens-genéricas) common.json ```json { "messages": { "success": { "saved": "Salvo com sucesso", "created": "Criado com sucesso", "updated": "Atualizado com sucesso", "deleted": "Excluído com sucesso", "sent": "Enviado com sucesso", "copied": "Copiado para a área de transferência" }, "error": { "generic": "Ocorreu um erro. Tente novamente.", "connection": "Erro de conexão. Verifique sua internet.", "load": "Erro ao carregar dados", "save": "Erro ao salvar", "delete": "Erro ao excluir", "permission": "Você não tem permissão para esta ação" }, "confirmation": { "delete": "Tem certeza que deseja excluir?", "cancel": "Tem certeza que deseja cancelar?", "exit": "Tem certeza que deseja sair?", "discard": "Descartar alterações não salvas?" }, "empty": { "list": "Nenhum item encontrado", "search": "Nenhum resultado para sua busca", "data": "Sem dados para exibir" } } } ``` **Uso:** ```tsx toast.success(t("common:messages.success.saved")); toast.error(t("common:messages.error.generic")); ``` #### Tempo e Datas [Seção intitulada “Tempo e Datas”](#tempo-e-datas) common.json ```json { "time": { "today": "Hoje", "yesterday": "Ontem", "tomorrow": "Amanhã", "now": "Agora", "just_now": "Há pouco", "seconds": "{{count}} segundo", "seconds_plural": "{{count}} segundos", "minutes": "{{count}} minuto", "minutes_plural": "{{count}} minutos", "hours": "{{count}} hora", "hours_plural": "{{count}} horas", "days": "{{count}} dia", "days_plural": "{{count}} dias", "weeks": "{{count}} semana", "weeks_plural": "{{count}} semanas", "months": "{{count}} mês", "months_plural": "{{count}} meses" } } ``` **Uso:** ```tsx t("common:time.days", { count: 5 }); // "5 dias" t("common:time.today"); // "Hoje" ``` #### Paginação e Listas [Seção intitulada “Paginação e Listas”](#paginação-e-listas) common.json ```json { "pagination": { "page": "Página {{current}} de {{total}}", "items": "{{count}} item", "items_plural": "{{count}} itens", "showing": "Mostrando {{from}} a {{to}} de {{total}}", "per_page": "Por página", "first": "Primeira", "last": "Última" } } ``` #### Regra de Ouro [Seção intitulada “Regra de Ouro”](#regra-de-ouro) ```tsx // ❌ ERRADO - Criar key específica para algo comum t("workflows:save"); t("inbox:cancel_button"); t("settings:delete_account"); // ✅ CORRETO - Usar vocabulary padrão t("common:actions.save"); t("common:actions.cancel"); t("common:actions.delete"); // ✅ CORRETO - Key específica apenas para contexto único t("settings:account.delete_permanently"); // Ação específica com consequência diferente ``` ### Criar Novo Namespace [Seção intitulada “Criar Novo Namespace”](#criar-novo-namespace) Guia passo a passo para adicionar um novo escopo de tradução. #### Criar Arquivos JSON [Seção intitulada “Criar Arquivos JSON”](#criar-arquivos-json) ```bash # Estrutura de arquivos packages/i18n/src/locales/ ├── pt-BR/ │ └── campaigns.json # NOVO └── en/ └── campaigns.json # NOVO ``` packages/i18n/src/locales/pt-BR/campaigns.json ```json { "title": "Campanhas", "create": "Criar campanha", "edit": "Editar campanha", "delete": "Excluir campanha", "status": { "active": "Ativa", "paused": "Pausada", "finished": "Finalizada" }, "messages": { "created_success": "Campanha criada com sucesso", "updated_success": "Campanha atualizada com sucesso", "deleted_success": "Campanha excluída com sucesso", "error_create": "Erro ao criar campanha" } } ``` packages/i18n/src/locales/en/campaigns.json ```json { "title": "Campaigns", "create": "Create campaign", "edit": "Edit campaign", "delete": "Delete campaign", "status": { "active": "Active", "paused": "Paused", "finished": "Finished" }, "messages": { "created_success": "Campaign created successfully", "updated_success": "Campaign updated successfully", "deleted_success": "Campaign deleted successfully", "error_create": "Error creating campaign" } } ``` #### Registrar no Index de Locales [Seção intitulada “Registrar no Index de Locales”](#registrar-no-index-de-locales) packages/i18n/src/locales/pt-BR/index.ts ```typescript import auth from "./auth.json"; import campaigns from "./campaigns.json"; import common from "./common.json"; import errors from "./errors.json"; import inbox from "./inbox.json"; import settings from "./settings.json"; import validation from "./validation.json"; import workflows from "./workflows.json"; export default { auth, campaigns, common, errors, inbox, settings, validation, workflows, }; ``` #### Adicionar nas Constantes [Seção intitulada “Adicionar nas Constantes”](#adicionar-nas-constantes) packages/i18n/src/core/constants.ts ```typescript export const NAMESPACES = [ "auth", "campaigns", "common", "errors", "inbox", "settings", "validation", "workflows", ] as const; ``` #### Atualizar Tipagem TypeScript [Seção intitulada “Atualizar Tipagem TypeScript”](#atualizar-tipagem-typescript) packages/i18n/src/types/resources.d.ts ```typescript import "i18next"; import auth from "../locales/pt-BR/auth.json"; import campaigns from "../locales/pt-BR/campaigns.json"; import common from "../locales/pt-BR/common.json"; import errors from "../locales/pt-BR/errors.json"; import inbox from "../locales/pt-BR/inbox.json"; import settings from "../locales/pt-BR/settings.json"; import validation from "../locales/pt-BR/validation.json"; import workflows from "../locales/pt-BR/workflows.json"; declare module "i18next" { interface CustomTypeOptions { defaultNS: "common"; resources: { auth: typeof auth; campaigns: typeof campaigns; common: typeof common; errors: typeof errors; inbox: typeof inbox; settings: typeof settings; validation: typeof validation; workflows: typeof workflows; }; } } ``` #### Usar o Novo Namespace [Seção intitulada “Usar o Novo Namespace”](#usar-o-novo-namespace) packages/console/src/screens/dashboard/campaigns/index.tsx ```tsx import { useT } from "@gaio/i18n/react"; function CampaignsPage() { const { t } = useT("campaigns"); return (

{t("title")}

); } ``` ```tsx // Acesso a keys aninhadas t("campaigns:status.active"); // "Ativa" t("campaigns:messages.created_success"); // "Campanha criada com sucesso" ``` #### Checklist Novo Namespace [Seção intitulada “Checklist Novo Namespace”](#checklist-novo-namespace) * [ ] Criar `pt-BR/{namespace}.json` * [ ] Criar `en/{namespace}.json` * [ ] Adicionar import em `locales/pt-BR/index.ts` * [ ] Adicionar import em `locales/en/index.ts` * [ ] Adicionar em `NAMESPACES` em `constants.ts` * [ ] Adicionar em `resources.d.ts` para intellisense * [ ] Testar com `useT('namespace')` no componente *** ## Integração com Zod [Seção intitulada “Integração com Zod”](#integração-com-zod) O `zodMessages` fornece mensagens de validação traduzidas para schemas Zod. ### Mensagens Disponíveis [Seção intitulada “Mensagens Disponíveis”](#mensagens-disponíveis) | Função | Exemplo de Uso | Key | | --------------------- | --------------------------------------- | -------------------------------- | | `required()` | `.min(1, zodMessages.required())` | `validation:required` | | `minLength(n)` | `.min(3, zodMessages.minLength(3))` | `validation:min_length` | | `maxLength(n)` | `.max(100, zodMessages.maxLength(100))` | `validation:max_length` | | `invalidEmail()` | `.email(zodMessages.invalidEmail())` | `validation:invalid_email` | | `invalidUrl()` | `.url(zodMessages.invalidUrl())` | `validation:invalid_url` | | `invalidPhone()` | Custom validator | `validation:invalid_phone` | | `passwordMismatch()` | Refine de confirmação | `validation:password_mismatch` | | `passwordMinLength()` | `.min(8, ...)` | `validation:password_min_length` | | `passwordUppercase()` | Regex validator | `validation:password_uppercase` | | `passwordLowercase()` | Regex validator | `validation:password_lowercase` | | `passwordNumber()` | Regex validator | `validation:password_number` | | `passwordSpecial()` | Regex validator | `validation:password_special` | ### Exemplo de Schema [Seção intitulada “Exemplo de Schema”](#exemplo-de-schema) ```typescript import { z } from "zod"; import { zodMessages } from "@gaio/i18n/core"; export const RegisterSchema = z .object({ name: z .string() .min(1, zodMessages.required()) .max(100, zodMessages.maxLength(100)), email: z .string() .min(1, zodMessages.required()) .email(zodMessages.invalidEmail()), password: z .string() .min(8, zodMessages.passwordMinLength()) .regex(/[A-Z]/, zodMessages.passwordUppercase()) .regex(/[a-z]/, zodMessages.passwordLowercase()) .regex(/[0-9]/, zodMessages.passwordNumber()), confirmPassword: z.string(), }) .refine((data) => data.password === data.confirmPassword, { message: zodMessages.passwordMismatch(), path: ["confirmPassword"], }); ``` ### Mensagens Customizadas [Seção intitulada “Mensagens Customizadas”](#mensagens-customizadas) Para mensagens específicas de um form, use `i18n.t()` diretamente: ```typescript import { i18n, zodMessages } from "@gaio/i18n/core"; const WorkflowSchema = z.object({ name: z .string() .min(1, zodMessages.required()) .max(50, () => i18n.t("workflows:name_max_50")), }); ``` *** ## Pluralização e Interpolação [Seção intitulada “Pluralização e Interpolação”](#pluralização-e-interpolação) ### Pluralização [Seção intitulada “Pluralização”](#pluralização) i18next usa sufixos para pluralização: locales/pt-BR/common.json ```json { "message": "{{count}} mensagem", "message_plural": "{{count}} mensagens", "message_zero": "Nenhuma mensagem" } ``` ```typescript t("message", { count: 0 }); // "Nenhuma mensagem" t("message", { count: 1 }); // "1 mensagem" t("message", { count: 5 }); // "5 mensagens" ``` ### Regras de Sufixos [Seção intitulada “Regras de Sufixos”](#regras-de-sufixos) | Sufixo | Quando usado | | --------- | ---------------------- | | (nenhum) | count === 1 | | `_plural` | count !== 1 | | `_zero` | count === 0 (opcional) | ### Interpolação com Formatação [Seção intitulada “Interpolação com Formatação”](#interpolação-com-formatação) ```json { "price": "R$ {{value, number}}", "date": "{{date, datetime}}" } ``` ```typescript t("price", { value: 1234.56 }); // "R$ 1.234,56" (formatado pelo locale) ``` ### Context (Gênero) [Seção intitulada “Context (Gênero)”](#context-gênero) ```json { "user_updated": "Usuário atualizado", "user_updated_male": "Usuário atualizado", "user_updated_female": "Usuária atualizada" } ``` ```typescript t("user_updated", { context: "female" }); // "Usuária atualizada" ``` *** ## Melhores Práticas de Interpolação [Seção intitulada “Melhores Práticas de Interpolação”](#melhores-práticas-de-interpolação) ### Quando Usar Interpolação [Seção intitulada “Quando Usar Interpolação”](#quando-usar-interpolação) Use interpolação **apenas** para valores dinâmicos que só podem ser conhecidos em runtime: ```typescript // ✅ CORRETO - valor dinâmico (nome do usuário vem do banco) t("welcome", { name: user.name }); // ✅ CORRETO - valores numéricos dinâmicos t("items_count", { count: items.length }); // ✅ CORRETO - timestamps e datas t("last_updated", { date: formatDate(updatedAt) }); ``` ### Quando NÃO Usar Interpolação [Seção intitulada “Quando NÃO Usar Interpolação”](#quando-não-usar-interpolação) Evite interpolação para valores conhecidos no momento da tradução. Use chaves separadas: ```typescript // ❌ ERRADO - concatenação de strings t("error_prefix") + errorType + t("error_suffix"); // ❌ ERRADO - interpolação para valores conhecidos t("file_type_error", { type: "PDF" }); // ✅ CORRETO - chaves separadas e autocontidas t("errors.invalid_pdf_file"); t("errors.invalid_image_file"); t("errors.invalid_document_file"); ``` ### Tabela de Decisão [Seção intitulada “Tabela de Decisão”](#tabela-de-decisão) | Cenário | Abordagem | Exemplo | | ----------------------------- | --------------------------- | -------------------------------------------- | | Nome de usuário | Interpolação | `Olá, {{name}}` | | Contagens | Interpolação + Pluralização | `{{count}} mensagem` / `{{count}} mensagens` | | Tipos de arquivo conhecidos | Chaves separadas | `invalid_pdf_file`, `invalid_image_file` | | Status conhecidos | Chaves separadas | `status.active`, `status.inactive` | | Mensagens de erro específicas | Chaves separadas | `errors.network_error`, `errors.auth_error` | | Métodos de pagamento | Chaves separadas | `payment.credit_card`, `payment.paypal` | *** ## Formatação HTML em Traduções [Seção intitulada “Formatação HTML em Traduções”](#formatação-html-em-traduções) Quando traduções precisam conter formatação (negrito, itálico, links), use o componente `Trans` do `react-i18next` em vez de `dangerouslySetInnerHTML`. ### defaultTransComponents [Seção intitulada “defaultTransComponents”](#defaulttranscomponents) Use o helper `defaultTransComponents` para elementos HTML padrão: ```typescript import { defaultTransComponents } from "@gaio/i18n/react"; // Elementos suportados: // e // e //

// ``` ### Exemplo Completo [Seção intitulada “Exemplo Completo”](#exemplo-completo) ```tsx import { Trans, defaultTransComponents } from "@gaio/i18n/react"; export const InviteDialog = () => { return (

); }; ``` ### Quando Usar `Trans` vs `t()` [Seção intitulada “Quando Usar Trans vs t()”](#quando-usar-trans-vs-t) | Cenário | Use | Exemplo | | -------------------------------------- | ------------------------------- | ------------------------------------------------------------- | | Texto simples sem formatação | `t()` | `t('common:labels.save')` | | Texto com interpolação simples | `t()` | `t('messages.hello', { name })` | | Texto com **negrito**, *itálico*, etc. | `Trans` | `` | | Texto com links | `Trans` + `createLinkComponent` | Ver exemplo acima | *** ## Seletor de Idioma [Seção intitulada “Seletor de Idioma”](#seletor-de-idioma) ### Componente de Seleção [Seção intitulada “Componente de Seleção”](#componente-de-seleção) ```tsx import { useT, useLocale, changeLocale } from "@gaio/i18n/react"; import { LOCALE_LABELS, SUPPORTED_LOCALES } from "@gaio/i18n/core"; export function LanguageSelector() { const { t } = useT(); const currentLocale = useLocale(); return ( ); } ``` ### Persistência [Seção intitulada “Persistência”](#persistência) A preferência de idioma é salva automaticamente em um cookie `ga:i18n` (duração: 1 ano). Ordem de detecção: 1. `cookie` (preferência salva) 2. `navigator.language` (idioma do navegador) 3. `pt-BR` (fallback) *** ## CLI e Extração [Seção intitulada “CLI e Extração”](#cli-e-extração) ### Comandos [Seção intitulada “Comandos”](#comandos) ```bash # Extrair strings do código para os JSONs npm run i18n:extract -w @gaio/i18n # Verificar se há strings não extraídas (CI) npm run i18n:check -w @gaio/i18n ``` ### Workflow de Tradução [Seção intitulada “Workflow de Tradução”](#workflow-de-tradução) 1. Desenvolver feature com strings usando `t('key')` 2. Rodar `npm run i18n:extract -w @gaio/i18n` 3. Preencher traduções em `pt-BR/*.json` *** ## Validação i18n (CI/CD) [Seção intitulada “Validação i18n (CI/CD)”](#validação-i18n-cicd) ### Comandos da CLI Gaio [Seção intitulada “Comandos da CLI Gaio”](#comandos-da-cli-gaio) ```bash gaio i18n validate # Validação completa (usado no CI) gaio i18n validate --fix # Validação com auto-fix de chaves órfãs gaio i18n extract # Extrair chaves de tradução do código gaio i18n status # Ver status das traduções (completude) gaio i18n lint # Detectar hardcoded strings ``` ### O que é Validado [Seção intitulada “O que é Validado”](#o-que-é-validado) 1. **Chaves Órfãs** - Chaves que existem nos JSONs mas não são usadas no código 2. **Hardcoded Strings** - Strings visíveis ao usuário que não usam `t()` 3. **Status das Traduções** - Chaves faltantes entre idiomas ### Workflow de Desenvolvimento [Seção intitulada “Workflow de Desenvolvimento”](#workflow-de-desenvolvimento) 1. `gaio i18n extract` - Extrair novas chaves 2. Preencher traduções em ambos idiomas 3. `gaio i18n validate` - Validar localmente 4. Corrigir problemas com `--fix` se necessário *** ## Testes [Seção intitulada “Testes”](#testes) ### Test Provider [Seção intitulada “Test Provider”](#test-provider) ```tsx import { I18nProvider } from "@gaio/i18n/react"; export function TestI18nWrapper({ children }: { children: ReactNode }) { return {children}; } render( , ); ``` ### Mock de Traduções [Seção intitulada “Mock de Traduções”](#mock-de-traduções) ```typescript vi.mock("@gaio/i18n/react", () => ({ useT: () => ({ t: (key: string) => key, }), })); ``` *** ## Anti-patterns [Seção intitulada “Anti-patterns”](#anti-patterns) ```tsx // ❌ ERRADO - Strings hardcoded ; // ✅ CORRETO const { t } = useT(); ; ``` ```tsx // ❌ ERRADO - Concatenação de strings t("you_have") + count + t("messages"); // ✅ CORRETO - Interpolação t("you_have_messages", { count }); ``` ```tsx // ❌ ERRADO - Keys em português t("validation:campo_obrigatorio"); // ✅ CORRETO t("validation:required"); ``` ```tsx // ❌ ERRADO - useT em loops { items.map((item) => { const { t } = useT(); // Hook em cada iteração! return {t("item")}; }); } // ✅ CORRETO const { t } = useT(); { items.map((item) => {t("item")}); } ``` *** ## TypeScript [Seção intitulada “TypeScript”](#typescript) Para ter intellisense das variáveis de interpolação em cada tradução, configure os tipos: packages/i18n/src/types/resources.d.ts ```typescript import "i18next"; import common from "../locales/pt-BR/common.json"; // ... outros imports declare module "i18next" { interface CustomTypeOptions { defaultNS: "common"; resources: { common: typeof common; // ... outros namespaces }; } } ``` ### Benefícios [Seção intitulada “Benefícios”](#benefícios) ```typescript // ✅ Autocomplete de keys t("welcome"); // Sugere todas as keys disponíveis // ✅ Erro se key não existe t("key_inexistente"); // TypeScript error! // ✅ Autocomplete de variáveis de interpolação t("welcome", { name: "João" }); // TypeScript sabe que 'name' é obrigatório ``` *** ## Referências [Seção intitulada “Referências”](#referências) * **i18next:** * **react-i18next:** * **TypeScript setup:** # Visão Geral > Arquitetura e conceitos do frontend GAIO Documentação do frontend do ecossistema GAIO. ## Índice [Seção intitulada “Índice”](#índice) * [Stack Técnica](/frontend/general/) — Tecnologias, padrões e convenções do projeto * [Gaio CLI](/cli/overview/) — Comandos e ferramentas da CLI * [Internacionalização (i18n)](/frontend/i18n/) — Guia completo de i18n com i18next * [Formulários](/frontend/forms/) — Padrões com React Hook Form + Zod * [Token Refresh](/frontend/auth-refresh/) — Coordenação de refresh com single-flight pattern ### Testing [Seção intitulada “Testing”](#testing) * [Arquitetura de Testes](/frontend/testing/overview/) — Estrutura e organização dos testes * [Shared Utilities](/frontend/testing/utils/shared/) — Utilitários compartilhados entre testes * [Utils](/frontend/testing/utils/overview/) — Vitest e MSW helpers * [E2E](/frontend/testing/e2e/) — Testes end-to-end com Playwright * [API Mocks](/frontend/testing/api-mocks/) — Padrões de mock para E2E ### Templates [Seção intitulada “Templates”](#templates) * [Fluxos](/frontend/templates/flows/) — Fluxos do funil de templates * [KPIs](/frontend/templates/kpis-funnel/) — Métricas e KPIs do funil * [Tracking](/frontend/templates/tracking/) — Eventos e analytics com PostHog # Fluxos > Fluxos de usuário e jornadas Este documento descreve o fluxo de instalação de templates. O objetivo é suportar instalação com ou sem plano/cupom, usando o caminho: **Auth → Profile → Checkout → Success**. ## Etapas do Funil [Seção intitulada “Etapas do Funil”](#etapas-do-funil) | Step | Descrição | Rota | | ------------------ | ------------------------------------------- | ------------------------------------- | | `template_view` | Visualização do template | `/t/{slug}` | | `auth_pending` | Autenticação | `/t/{slug}/install/` | | `profile_pending` | Completar perfil + aceitar compartilhamento | `/t/{slug}/install/profile` | | `checkout_pending` | Checkout | `/t/{slug}/install/checkout` | | `success` | Sucesso | `/t/{subscriptionId}/install/success` | ## Transições principais [Seção intitulada “Transições principais”](#transições-principais) | Estado Atual | Evento | Próximo Estado | Observação | | ------------------ | ----------------- | ------------------ | ----------------------------- | | `template_view` | `install_click` | `auth_pending` | Início do funil | | `auth_pending` | `auth_success` | `profile_pending` | Usuário sem perfil completo | | `auth_pending` | `auth_success` | `checkout_pending` | Usuário com perfil completo | | `auth_pending` | `auth_error` | `auth_pending` | Exibe erro e permite retry | | `profile_pending` | `profile_submit` | `checkout_pending` | Dados válidos + consentimento | | `checkout_pending` | `payment_success` | `success` | Assinatura criada | | `checkout_pending` | `payment_error` | `checkout_pending` | Exibe erro e permite retry | ## Regras de navegação [Seção intitulada “Regras de navegação”](#regras-de-navegação) * Se o usuário já estiver autenticado e com perfil completo, pode ir direto para `checkout_pending`. * Se não houver plano/cupom, o checkout pode ser apresentado como **R$ 0** ou com plano nulo (de acordo com o backend). * O `subscriptionId` é usado apenas na rota de sucesso. * O consentimento de compartilhamento é obrigatório para avançar do perfil para o checkout. ## Estado mínimo do funil [Seção intitulada “Estado mínimo do funil”](#estado-mínimo-do-funil) ```ts interface InstallationState { sessionId: string | null; currentStep: | "template_view" | "auth_pending" | "profile_pending" | "checkout_pending" | "success"; completedSteps: Array< | "template_view" | "auth_pending" | "profile_pending" | "checkout_pending" | "success" >; templateId?: string; templateSlug?: string; planId?: string | null; couponId?: string | null; consent?: boolean; } ``` ## Eventos de tracking [Seção intitulada “Eventos de tracking”](#eventos-de-tracking) Os eventos estão em [Tracking](/frontend/templates/tracking). Eventos adicionais podem ser criados para insights mais detalhados, mas não devem ser usados para controle de fluxo. # KPIs > Métricas e conversão Este documento descreve os KPIs mínimos para o funil de templates, alinhados ao tracking. ## Etapas [Seção intitulada “Etapas”](#etapas) ### Template [Seção intitulada “Template”](#template) * **Install Clicks** = `COUNT(template_install_click)` ### Auth [Seção intitulada “Auth”](#auth) * **Auth Starts** = `COUNT(auth_register_start) + COUNT(auth_login_start) + COUNT(auth_instagram_start)` * **Auth Completions** = `COUNT(auth_register_success) + COUNT(auth_login_success) + COUNT(auth_instagram_success)` * **Auth Errors** = `COUNT(auth_register_failure) + COUNT(auth_login_failure) + COUNT(auth_instagram_failure)` * **Auth Success Rate** = `auth_completions / auth_starts` ### Profile (via Pageviews) [Seção intitulada “Profile (via Pageviews)”](#profile-via-pageviews) * **Profile Views** = `COUNT($pageview WHERE $current_url CONTAINS '/install/profile')` * **Profile Completed** = `COUNT(profile_completed)` * **Drop-off Auth → Profile** = `1 - (profile_views / auth_completions)` ### Checkout [Seção intitulada “Checkout”](#checkout) * **Checkout Views** = `COUNT(checkout_viewed)` * **Checkout Submissions** = `COUNT(checkout_submitted)` * **Checkout Completions** = `COUNT(checkout_completed)` * **Checkout Error Rate** = `checkout_errors / checkout_submitted` * **Checkout Conversion Rate** = `checkout_completions / checkout_views` ### Funil completo [Seção intitulada “Funil completo”](#funil-completo) * **Total Funnel Conversion** = `template_signup_complete / template_install_click` * **Churn por Etapa** = Usar funil de pageviews (Auth → Profile → Consent → Checkout → Success) ## Dashboard PostHog [Seção intitulada “Dashboard PostHog”](#dashboard-posthog) O dashboard **Funil de Templates** contém todos os insights necessários: ### Insights de Checkout [Seção intitulada “Insights de Checkout”](#insights-de-checkout) * **Funil de Checkout Detalhado** - checkout\_viewed → submitted → completed * **Erros vs Submissões** - Taxa de erro ao longo do tempo * **Tipos de Erro** - Breakdown por error\_message * **Views vs Conclusões** - Gap de conversão no checkout ### Insights de Pageviews [Seção intitulada “Insights de Pageviews”](#insights-de-pageviews) * **Funil Completo por Páginas** - Conversão entre todas as etapas * **Pageviews por Etapa** - Volume de cada etapa ao longo do tempo ### Insights de Auth [Seção intitulada “Insights de Auth”](#insights-de-auth) * **Distribuição de Métodos** - Register vs Login vs Instagram * **Funil de Register** - auth\_register\_start → success * **Funil de Login** - auth\_login\_start → success ## Segmentações recomendadas [Seção intitulada “Segmentações recomendadas”](#segmentações-recomendadas) * `plan_id` (com plano vs sem plano) * `coupon_id` (com cupom vs sem cupom) * `is_zero_cost` (cupom 100% de desconto) * `auth_method` (registration, login, instagram) * `error_message` (tipos de erro no checkout) ## Eventos usados [Seção intitulada “Eventos usados”](#eventos-usados) Todos os eventos estão definidos em [Tracking](/frontend/templates/tracking). **Eventos principais**: * `template_install_click`, `template_signup_complete` * Auth: `auth_{method}_{outcome}` (register/login/instagram + start/success/failure) * Profile: `profile_completed`, `consent_accepted` * Checkout: `checkout_viewed`, `checkout_submitted`, `checkout_completed`, `checkout_error` * Pageviews: `$pageview` com filtros de URL para cada etapa do funil # Tracking > Eventos e monitoramento O objetivo é medir toda a jornada do usuário ao ativar um template **com ou sem plano/cupom**, sem eventos desnecessários. ## Escopo [Seção intitulada “Escopo”](#escopo) Eventos **implementados**: * `template_install_click` - Clique no botão instalar * `template_signup_complete` - Conclusão com sucesso * **Auth Registration**: `auth_register_start`, `auth_register_success`, `auth_register_failure` * **Auth Login**: `auth_login_start`, `auth_login_success`, `auth_login_failure` * **Auth Instagram**: `auth_instagram_start`, `auth_instagram_success`, `auth_instagram_failure` * **Profile**: `profile_completed` - Conclusão do perfil * **Consent**: `consent_accepted` - Aceitação de compartilhamento * **Checkout**: `checkout_viewed`, `checkout_submitted`, `checkout_completed`, `checkout_error` ## Propriedades comuns [Seção intitulada “Propriedades comuns”](#propriedades-comuns) Todos os eventos incluem, quando disponíveis: * `template_id` - ID único do template * `template_slug` - Slug amigável do template * `plan_id` - ID do plano (se aplicável) * `amount_in_cents` - Valor do plano em centavos * `coupon_id` - ID do cupom (se aplicável) * `percent_off` - Percentual de desconto do cupom * `duration_in_months` - Duração do desconto em meses * `applies_first_month_only` - Se o desconto aplica apenas ao primeiro mês **Nota**: O `funnel_session_id` (PostHog session) é gerenciado automaticamente pelo SDK ## Eventos e payloads [Seção intitulada “Eventos e payloads”](#eventos-e-payloads) ### Eventos do Funil Principal [Seção intitulada “Eventos do Funil Principal”](#eventos-do-funil-principal) #### `template_install_click` [Seção intitulada “template\_install\_click”](#template_install_click) Disparado quando o usuário clica no botão instalar. ```ts interface TemplateInstallClickEvent { template_id: string; template_slug: string; plan_id?: string; coupon_id?: string; amount_in_cents?: number; } ``` #### `template_form_start` [Seção intitulada “template\_form\_start”](#template_form_start) Disparado quando o usuário inicia o preenchimento do formulário de perfil. ```ts interface TemplateFormStartEvent { template_id: string; template_slug: string; plan_id?: string; coupon_id?: string; } ``` #### `template_form_drop` [Seção intitulada “template\_form\_drop”](#template_form_drop) Disparado quando o usuário abandona o funil (inatividade ou navegação para fora). ```ts interface TemplateFormDropEvent { template_id: string; template_slug: string; plan_id?: string; coupon_id?: string; abandonment_point: 'account' | 'contact' | 'wizard_profile' | 'wizard_discovery' | 'activation'; } ``` #### `template_signup_complete` [Seção intitulada “template\_signup\_complete”](#template_signup_complete) Disparado quando o usuário chega na página de sucesso após ativação. ```ts interface TemplateSignupCompleteEvent { template_id: string; template_slug: string; plan_id?: string; coupon_id?: string; amount_in_cents?: number; } ``` *** ### Eventos de Autenticação [Seção intitulada “Eventos de Autenticação”](#eventos-de-autenticação) Todos os eventos de auth seguem o padrão: `auth_{method}_{outcome}` onde: * **method**: `register`, `login`, `instagram` * **outcome**: `start`, `success`, `failure` #### `auth_register_start` / `auth_register_success` / `auth_register_failure` [Seção intitulada “auth\_register\_start / auth\_register\_success / auth\_register\_failure”](#auth_register_start--auth_register_success--auth_register_failure) ```ts interface AuthRegisterEvent { auth_method: 'registration'; template_id?: string; template_slug?: string; error_code?: string; // apenas em failure error_message?: string; // apenas em failure } ``` #### `auth_login_start` / `auth_login_success` / `auth_login_failure` [Seção intitulada “auth\_login\_start / auth\_login\_success / auth\_login\_failure”](#auth_login_start--auth_login_success--auth_login_failure) ```ts interface AuthLoginEvent { auth_method: 'login'; template_id?: string; template_slug?: string; error_code?: string; // apenas em failure error_message?: string; // apenas em failure } ``` #### `auth_instagram_start` / `auth_instagram_success` / `auth_instagram_failure` [Seção intitulada “auth\_instagram\_start / auth\_instagram\_success / auth\_instagram\_failure”](#auth_instagram_start--auth_instagram_success--auth_instagram_failure) ```ts interface AuthInstagramEvent { auth_method: 'instagram'; template_id?: string; template_slug?: string; error_code?: string; // apenas em failure error_message?: string; // apenas em failure } ``` *** ### Eventos de Community Modal [Seção intitulada “Eventos de Community Modal”](#eventos-de-community-modal) #### `community_modal_view` [Seção intitulada “community\_modal\_view”](#community_modal_view) Disparado quando o modal de preview é aberto ou fechado. ```ts interface CommunityModalViewEvent { template_id: string; template_slug: string; modal_state: 'open' | 'close'; } ``` #### `community_modal_install_click` [Seção intitulada “community\_modal\_install\_click”](#community_modal_install_click) Disparado quando o usuário clica em instalar dentro do modal da comunidade. ```ts interface CommunityModalInstallClickEvent { template_id: string; template_slug: string; plan_id?: string; coupon_id?: string; } ``` ## Tracking por Pageviews [Seção intitulada “Tracking por Pageviews”](#tracking-por-pageviews) Para medir churn entre etapas do funil, use **pageviews automáticos** do PostHog ao invés de eventos customizados: ### URLs do Funil [Seção intitulada “URLs do Funil”](#urls-do-funil) * **Auth**: `/t/{slug}/install` - Página de autenticação * **Profile**: `/t/{slug}/install/profile` - Página de perfil * **Consent**: `/t/{slug}/install/consent` - Página de consentimento * **Checkout**: `/t/{slug}/install/checkout` - Página de checkout * **Success**: `/t/{slug}/install/success` - Página de sucesso ### Insights Baseados em Pageviews [Seção intitulada “Insights Baseados em Pageviews”](#insights-baseados-em-pageviews) 1. **Funil Completo por Páginas** - Mede conversão Auth → Profile → Consent → Checkout → Success 2. **Pageviews por Etapa - Tendência** - Compara volume de cada etapa ao longo do tempo 3. **Churn entre Etapas** - Diferença entre pageviews consecutivas identifica abandono **Vantagens**: * Sem código adicional (autocapture do PostHog) * Tempo médio entre etapas calculado automaticamente * Sessões rastreadas automaticamente * Menos eventos customizados = menos manutenção # API Mocks > Helpers para mockar APIs em testes E2E Helpers reutilizáveis para mockar APIs nos testes Playwright. **Use sempre esses helpers** em vez de criar mocks manualmente. > **Ver também**: [Arquitetura de Testes](/frontend/testing/overview) | [Guia E2E](/frontend/testing/e2e) ## Objetivo [Seção intitulada “Objetivo”](#objetivo) * ✅ **Consistência**: Todos os testes usam os mesmos mocks * ✅ **Manutenibilidade**: Mocks centralizados em um único lugar * ✅ **Produtividade**: Setup rápido com funções pré-configuradas * ✅ **Redução de código**: Menos boilerplate nos testes ## Uso Rápido [Seção intitulada “Uso Rápido”](#uso-rápido) ### Setup Completo (Fluxo de Template) [Seção intitulada “Setup Completo (Fluxo de Template)”](#setup-completo-fluxo-de-template) ```typescript import { setupTemplateFlowMocks } from "../helpers"; test.beforeEach(async ({ page }) => { await setupTemplateFlowMocks(page, { templateId: "template-123", templateSlug: "test-template", templateName: "My Template", planId: null, // FREE ou 'plan-id' para PAID }); }); ``` ### Setup Modular [Seção intitulada “Setup Modular”](#setup-modular) ```typescript import { setupCommonApiMocks, setupAuthMocks, setupTemplateMocks, } from "../helpers"; test.beforeEach(async ({ page }) => { await setupCommonApiMocks(page); await setupAuthMocks(page); await setupTemplateMocks(page, { templateId: "my-template" }); }); ``` ## Helpers Disponíveis [Seção intitulada “Helpers Disponíveis”](#helpers-disponíveis) ### `setupCommonApiMocks(page)` [Seção intitulada “setupCommonApiMocks(page)”](#setupcommonapimockspage) Mocks básicos necessários para páginas autenticadas: * ✅ Current user * ✅ Connections * ✅ Subscription status * ✅ Account info * ✅ Catch-all para endpoints não mockados **Quando usar**: Em praticamente todos os testes. *** ### `setupAuthMocks(page, options?)` [Seção intitulada “setupAuthMocks(page, options?)”](#setupauthmockspage-options) Mocks de autenticação: * ✅ Login (`/api/console/login`) * ✅ Sign-in (`/api/console/sign-in`) * ✅ Sign-up (`/api/console/sign-up`) * ✅ Email verification **Opções**: ```typescript { invalidCredentials?: string[], // Emails com credenciais inválidas existingEmails?: string[], // Emails já cadastrados unverifiedEmails?: string[] // Emails não verificados } ``` **Exemplo**: ```typescript await setupAuthMocks(page, { invalidCredentials: ["wrong@example.com"], existingEmails: ["existing@example.com"], }); ``` *** ### `setupProfileMocks(page)` [Seção intitulada “setupProfileMocks(page)”](#setupprofilemockspage) Mocks de atualização de perfil: * ✅ Contact info (`/api/console/profile/contact-info`) * ✅ Account metadata (`/api/console/account/metadata`) * ✅ Update user (`/api/console/update-user`) **Quando usar**: Testes que envolvem edição de perfil ou wizard. *** ### `setupTemplateMocks(page, options?)` [Seção intitulada “setupTemplateMocks(page, options?)”](#setuptemplatemockspage-options) Mocks relacionados a templates: * ✅ Template details (public) * ✅ Template installation * ✅ Template list **Opções**: ```typescript { templateId?: string, templateSlug?: string, templateName?: string, planId?: string | null, // null = FREE, string = PAID planPrice?: number | null, couponId?: string | null, isInstalled?: boolean } ``` **Exemplo - Template FREE**: ```typescript await setupTemplateMocks(page, { templateSlug: "free-template", planId: null, }); ``` **Exemplo - Template PAID**: ```typescript await setupTemplateMocks(page, { templateSlug: "premium-template", planId: "plan-premium", planPrice: 9900, couponId: "WELCOME50", }); ``` *** ### `setupPaymentMocks(page, options?)` [Seção intitulada “setupPaymentMocks(page, options?)”](#setuppaymentmockspage-options) Mocks de pagamento (Stripe): * ✅ Setup intent * ✅ Payment method attach * ✅ Create subscription **Opções**: ```typescript { setupIntentSecret?: string, paymentMethodId?: string, subscriptionId?: string, shouldFailPayment?: boolean // Simular erro de pagamento } ``` **Exemplo - Pagamento com sucesso**: ```typescript await setupPaymentMocks(page); ``` **Exemplo - Simular falha**: ```typescript await setupPaymentMocks(page, { shouldFailPayment: true, }); ``` *** ### `setupTrackingMocks(page)` [Seção intitulada “setupTrackingMocks(page)”](#setuptrackingmockspage) Mocks de analytics/tracking: * ✅ Track events (`/api/*/track`) * ✅ Identify (`/api/*/identify`) **Quando usar**: Sempre (já incluído em `setupTemplateFlowMocks`). *** ### `setupTemplateFlowMocks(page, options?)` [Seção intitulada “setupTemplateFlowMocks(page, options?)”](#setuptemplateflowmockspage-options) **Helper all-in-one** que combina todos os mocks acima. **Use este para testes de template**. Inclui: * ✅ Common API mocks * ✅ Auth mocks * ✅ Profile mocks * ✅ Template mocks * ✅ Payment mocks * ✅ Tracking mocks **Opções**: Mesmas de `setupTemplateMocks()`. **Exemplo**: ```typescript test.beforeEach(async ({ page }) => { await setupTemplateFlowMocks(page, { templateSlug: "my-template", planId: "plan-123", }); }); ``` ## Exemplos Práticos [Seção intitulada “Exemplos Práticos”](#exemplos-práticos) ### Teste de Template Gratuito [Seção intitulada “Teste de Template Gratuito”](#teste-de-template-gratuito) ```typescript import { setupTemplateFlowMocks } from "../helpers"; test.describe("Free Template Flow", () => { test.beforeEach(async ({ page }) => { await setupTemplateFlowMocks(page, { templateId: "free-123", templateSlug: "free-template", planId: null, // FREE }); }); test("should install free template", async ({ page }) => { // Teste aqui... }); }); ``` ### Teste de Template Pago [Seção intitulada “Teste de Template Pago”](#teste-de-template-pago) ```typescript import { setupTemplateFlowMocks } from "../helpers"; test.describe("Paid Template Flow", () => { test.beforeEach(async ({ page }) => { await setupTemplateFlowMocks(page, { templateId: "paid-123", templateSlug: "premium-template", planId: "plan-premium", planPrice: 9900, }); }); test("should show checkout", async ({ page }) => { // Teste aqui... }); }); ``` ### Teste com Erro de Login [Seção intitulada “Teste com Erro de Login”](#teste-com-erro-de-login) ```typescript import { setupCommonApiMocks, setupAuthMocks } from "../helpers"; test.describe("Login Errors", () => { test.beforeEach(async ({ page }) => { await setupCommonApiMocks(page); await setupAuthMocks(page, { invalidCredentials: ["wrong@example.com"], }); }); test("should show error for invalid credentials", async ({ page }) => { // Teste aqui... }); }); ``` ### Customizando Tracking [Seção intitulada “Customizando Tracking”](#customizando-tracking) ```typescript import { setupTemplateFlowMocks } from "../helpers"; test.beforeEach(async ({ page }) => { const trackedEvents: any[] = []; await setupTemplateFlowMocks(page, { templateSlug: "test-template", }); // Override tracking para capturar eventos await page.route("**/api/*/track", async (route) => { const data = route.request().postDataJSON(); trackedEvents.push(data); await route.fulfill({ status: 200, body: "{}" }); }); }); ``` ## Boas Práticas [Seção intitulada “Boas Práticas”](#boas-práticas) ### ✅ Fazer [Seção intitulada “✅ Fazer”](#-fazer) ```typescript // ✅ Usar helpers pré-configurados await setupTemplateFlowMocks(page, { templateSlug: "test" }); // ✅ Customizar apenas o necessário await setupAuthMocks(page, { invalidCredentials: ["bad@test.com"] }); // ✅ Setup modular quando não precisa de tudo await setupCommonApiMocks(page); await setupAuthMocks(page); ``` ### ❌ Evitar [Seção intitulada “❌ Evitar”](#-evitar) ```typescript // ❌ Não criar mocks manualmente await page.route('**/api/console/login', async (route) => { await route.fulfill({ ... }); // Use setupAuthMocks() em vez disso }); // ❌ Não duplicar código entre testes test.beforeEach(async ({ page }) => { // 50 linhas de mocks manuais... ❌ }); ``` ## Estendendo os Helpers [Seção intitulada “Estendendo os Helpers”](#estendendo-os-helpers) Se precisar de novos mocks, **adicione no arquivo `api-mocks.ts`**: ```typescript export const setupMyNewMocks = async (page: Page) => { await page.route("**/api/my-endpoint", async (route) => { await createJsonResponse(route, { data: "test" }); }); }; ``` ## Referências [Seção intitulada “Referências”](#referências) * [Playwright Route Mocking](https://playwright.dev/docs/network#handle-requests) * [Utilitários Compartilhados](/frontend/testing/utils/shared) - Fixtures compartilhadas # E2E > Testes End-to-End com Playwright Este diretório contém os testes End-to-End (E2E) da aplicação Gaio Console usando Playwright. > 📚 **Documentação completa**: Veja [Arquitetura de Testes](/frontend/testing/overview/) para arquitetura geral e guias de testes. ## Estrutura [Seção intitulada “Estrutura”](#estrutura) ```plaintext e2e/ ├── pages/ # Page Objects (seguindo o Page Object Model) │ ├── base.page.ts # Classe base com funcionalidades comuns │ ├── register.page.ts │ ├── verify-email-*.page.ts │ ├── forgot-password-*.page.ts │ ├── reset-password.page.ts │ ├── accept-invite.page.ts │ ├── template-preview.page.ts │ ├── template-install-login.page.ts │ ├── template-install-contact.page.ts │ ├── template-install-wizard-profile.page.ts │ ├── template-install-wizard-discovery.page.ts │ ├── template-install-checkout.page.ts │ └── template-install-activation.page.ts ├── specs/ # Arquivos de teste │ ├── signup-verify-email.spec.ts │ ├── forgot-password.spec.ts │ ├── accept-invite.spec.ts │ ├── template-install-free.spec.ts # Testes de instalação de template gratuito │ └── template-install-paid.spec.ts # Testes de instalação de template pago └── README.md ``` ## Como Executar [Seção intitulada “Como Executar”](#como-executar) ### Pré-requisitos [Seção intitulada “Pré-requisitos”](#pré-requisitos) Instale os browsers do Playwright (apenas necessário na primeira vez): ```bash npx playwright install ``` ### Executar Testes [Seção intitulada “Executar Testes”](#executar-testes) ```bash # Executar todos os testes E2E npm run test:e2e # Executar com interface visual npm run test:e2e:ui # Executar em modo debug npm run test:e2e:debug # Ver relatório após execução npm run test:e2e:report # Code generator (para criar novos testes) npm run test:e2e:codegen ``` ### Executar do Diretório Raiz [Seção intitulada “Executar do Diretório Raiz”](#executar-do-diretório-raiz) ```bash # Do diretório raiz do monorepo npm run test:e2e # Roda testes em todos os workspaces npm run test:e2e:ui # Roda com UI em todos os workspaces ``` ## Page Object Model [Seção intitulada “Page Object Model”](#page-object-model) Os testes seguem o padrão **Page Object Model (POM)**, onde cada página da aplicação tem uma classe correspondente que encapsula: * Seletores de elementos * Ações (click, fill, etc.) * Verificações específicas da página ## API Mocks Padronizados [Seção intitulada “API Mocks Padronizados”](#api-mocks-padronizados) Todos os testes E2E devem usar os **helpers de API mocks** para garantir consistência. Veja [helpers/api-mocks.ts](./helpers/api-mocks.ts). ### Exemplo Básico [Seção intitulada “Exemplo Básico”](#exemplo-básico) ```typescript import { setupTemplateFlowMocks } from '../helpers'; test.beforeEach(async ({ page }) => { // Setup completo de mocks para fluxo de template await setupTemplateFlowMocks(page, { templateId: 'template-123', templateSlug: 'test-template', planId: null // FREE template }); }); ``` ### Exemplo Avançado (Customizado) [Seção intitulada “Exemplo Avançado (Customizado)”](#exemplo-avançado-customizado) ```typescript import { setupCommonApiMocks, setupAuthMocks, setupTemplateMocks } from '../helpers'; test.beforeEach(async ({ page }) => { // Setup modular de mocks await setupCommonApiMocks(page); await setupAuthMocks(page, { invalidCredentials: ['invalid@example.com'], existingEmails: ['existing@example.com'] }); await setupTemplateMocks(page, { templateId: 'my-template', planId: 'plan-123', planPrice: 9900 }); }); ``` ### Helpers Disponíveis [Seção intitulada “Helpers Disponíveis”](#helpers-disponíveis) | Helper | Descrição | | -------------------------- | ------------------------------------------ | | `setupCommonApiMocks()` | Mocks básicos (user, connections, account) | | `setupAuthMocks()` | Login, signup, email verification | | `setupProfileMocks()` | Update de perfil e contato | | `setupTemplateMocks()` | Templates e instalação | | `setupPaymentMocks()` | Stripe, subscriptions | | `setupTrackingMocks()` | Analytics e tracking | | `setupTemplateFlowMocks()` | **Tudo acima** (fluxo completo) | Veja documentação completa em [helpers/api-mocks.ts](./helpers/api-mocks.ts). ### Exemplo [Seção intitulada “Exemplo”](#exemplo) ```typescript import { ForgotPasswordRequestPage } from '../pages/forgot-password-request.page'; test('should submit email', async ({ page }) => { const requestPage = new ForgotPasswordRequestPage(page); await requestPage.goto(); await requestPage.submitEmail('user@example.com'); // Página navega automaticamente para /verify }); ``` ## Browsers Suportados [Seção intitulada “Browsers Suportados”](#browsers-suportados) Os testes rodam em múltiplos browsers e viewports: * **Desktop**: Chromium, Firefox, WebKit (Safari) * **Mobile**: Chrome (Pixel 5), Safari (iPhone 12) Para rodar apenas em um browser específico: ```bash npx playwright test --project=chromium npx playwright test --project=firefox npx playwright test --project=webkit ``` ## Configuração [Seção intitulada “Configuração”](#configuração) A configuração está em [playwright.config.ts](../playwright.config.ts). ### Principais Configurações [Seção intitulada “Principais Configurações”](#principais-configurações) * **baseURL**: `http://localhost:5173` * **Retry**: 2 vezes apenas no CI * **Screenshot**: Capturado apenas em falhas * **Trace**: Coletado apenas no primeiro retry * **Video**: Mantido apenas em falhas * **Dev Server**: Inicia automaticamente com `npm run serve` ## Escrevendo Novos Testes [Seção intitulada “Escrevendo Novos Testes”](#escrevendo-novos-testes) 1. **Criar Page Object** (se necessário): e2e/pages/my-page.page.ts ```typescript import { BasePage } from './base.page'; export class MyPage extends BasePage { readonly myButton: Locator; constructor(page: Page) { super(page); this.myButton = page.getByRole('button', { name: /my button/i }); } async clickMyButton() { await this.myButton.click(); } } ``` 2. **Criar Teste**: e2e/specs/my-feature.spec.ts ```typescript import { test, expect } from '@playwright/test'; import { MyPage } from '../pages/my-page.page'; test('should do something', async ({ page }) => { const myPage = new MyPage(page); await myPage.goto('/my-path'); await myPage.clickMyButton(); await expect(myPage.myButton).toBeDisabled(); }); ``` ## Dicas [Seção intitulada “Dicas”](#dicas) * Use `test.only()` para rodar apenas um teste específico * Use `test.skip()` para pular um teste temporariamente * Use `page.pause()` para debug interativo * Use code generator para gerar seletores: `npm run test:e2e:codegen` ## Testes de Template Installation [Seção intitulada “Testes de Template Installation”](#testes-de-template-installation) Os testes de instalação de templates cobrem o fluxo completo de instalação de templates **gratuitos** e **pagos**: ### Fluxo de Template Gratuito (`template-install-free.spec.ts`) [Seção intitulada “Fluxo de Template Gratuito (template-install-free.spec.ts)”](#fluxo-de-template-gratuito-template-install-freespects) Testa o fluxo sem checkout: 1. Preview do template 2. Login/Autenticação 3. Informações de contato 4. Wizard de perfil 5. Wizard de discovery + consentimento 6. Ativação automática (sem pagamento) 7. Sucesso **Eventos de tracking validados:** * `template_viewed` * `template_install_clicked` * `auth_started`, `auth_completed` * `profile_viewed`, `profile_completed` * `checkout_submitted`, `checkout_completed` (sem `checkout_viewed`) * `success_viewed` * `funnel_abandoned` (quando aplicável) ### Fluxo de Template Pago (`template-install-paid.spec.ts`) [Seção intitulada “Fluxo de Template Pago (template-install-paid.spec.ts)”](#fluxo-de-template-pago-template-install-paidspects) Testa o fluxo com checkout: 1-5. Mesmos passos do fluxo gratuito 6. **Checkout** (formulário de pagamento Stripe) 7. Ativação após pagamento 8. Sucesso **Eventos de tracking adicionais:** * `checkout_viewed` (apenas para templates pagos) * `checkout_error` (em caso de falha no pagamento) * Todas as propriedades incluem `is_paid: true`, `plan_id`, `coupon_id` ### Executar apenas testes de template [Seção intitulada “Executar apenas testes de template”](#executar-apenas-testes-de-template) ```bash # Apenas templates gratuitos npx playwright test template-install-free # Apenas templates pagos npx playwright test template-install-paid # Todos os testes de template npx playwright test template-install ``` ## Documentação [Seção intitulada “Documentação”](#documentação) * [Playwright Docs](https://playwright.dev) * [Page Object Model](https://playwright.dev/docs/pom) * [Best Practices](https://playwright.dev/docs/best-practices) # Visão Geral > Estrutura e organização (Vitest vs Playwright) Estrutura organizada para evitar conflitos entre frameworks de teste (Vitest vs Playwright). ## Estrutura [Seção intitulada “Estrutura”](#estrutura) ```plaintext packages/console/ ├── test-shared/ # ✅ Utils compartilhados (SEM dependências de framework) │ ├── fixtures/ # Dados fixos (tokens, users, ids) │ └── factories/ # Criadores de objetos │ ├── test-utils/ # 🧪 Utils específicos do Vitest │ ├── handlers/ # MSW handlers │ ├── middlewares/ # MSW middlewares │ └── server.ts # MSW server setup │ └── e2e/ # 🎭 Testes Playwright ├── pages/ # Page Objects └── specs/ # Testes E2E ``` ## Regras de Importação [Seção intitulada “Regras de Importação”](#regras-de-importação) ### ✅ Permitido [Seção intitulada “✅ Permitido”](#-permitido) ```typescript // Testes Vitest podem importar de: import { TOKENS } from "../../test-shared"; // ✅ Shared import { mswServer } from "../../test-utils"; // ✅ Vitest utils // Testes Playwright podem importar de: import { TOKENS } from "../../test-shared"; // ✅ Shared import { LoginPage } from "../pages/login"; // ✅ Page Objects ``` ### ❌ Proibido (causa conflitos) [Seção intitulada “❌ Proibido (causa conflitos)”](#-proibido-causa-conflitos) ```typescript // ❌ Playwright NÃO deve importar de test-utils (tem dependências Vitest) import { mswServer } from "../../test-utils"; // ❌ ERRO! // ❌ Vitest NÃO deve importar de e2e/pages (são Page Objects do Playwright) import { LoginPage } from "../../e2e/pages"; // ❌ ERRO! ``` ## Framework Agnostic [Seção intitulada “Framework Agnostic”](#framework-agnostic) **Propósito**: Utils compartilhados sem dependências externas de testes. **Características**: * ✅ Zero dependências de frameworks de teste * ✅ Apenas JavaScript/TypeScript puro * ✅ Importável por Vitest E Playwright * ✅ Fixtures simples e factories leves **Conteúdo**: test-shared/fixtures/tokens.ts ```typescript export const TOKENS = { valid: () => generateMockToken(), expired: () => generateMockToken(expiredTime), }; // test-shared/factories/user.ts export const createUser = (overrides = {}) => ({ id: "test-user", name: "Test User", ...overrides, }); ``` ## Vitest Específico [Seção intitulada “Vitest Específico”](#vitest-específico) **Propósito**: Setup e mocks específicos do Vitest (MSW, etc). **Características**: * Apenas para testes Vitest * Pode ter dependências pesadas (MSW, faker, etc) * Inclui server MSW e handlers HTTP * Setup de testes de integração **Conteúdo**: test-utils/server.ts ```typescript import { setupServer } from "msw/node"; export const mswServer = setupServer(...handlers); // test-utils/handlers/auth.handlers.ts export const authHandlers = [ http.post("/api/login", () => { return HttpResponse.json({ token: TOKENS.valid() }); }), ]; ``` ## Playwright Específico [Seção intitulada “Playwright Específico”](#playwright-específico) **Propósito**: Testes End-to-End com Playwright. **Características**: * Testa fluxos completos da aplicação * Page Object Model * Foca em interação do usuário * Usa `test-shared/` para fixtures **Conteúdo**: e2e/pages/login.page.ts ```typescript export class LoginPage extends BasePage { async login(email, password) { ... } } // e2e/specs/login.spec.ts test('should login', async ({ page }) => { const loginPage = new LoginPage(page); await loginPage.login('user@test.com', 'pass'); }); ``` ## Por que essa separação? [Seção intitulada “Por que essa separação?”](#por-que-essa-separação) ### Problema Anterior [Seção intitulada “Problema Anterior”](#problema-anterior) ```plaintext ❌ Testes Playwright importavam de `test-utils/` ↓ `test-utils/` tinha dependências Vitest (MSW com Vitest) ↓ Playwright carregava código do Vitest ↓ CONFLITO: "Cannot redefine Symbol($$jest-matchers-object)" ``` ### Solução Atual [Seção intitulada “Solução Atual”](#solução-atual) ```plaintext ✅ Testes Playwright importam de `test-shared/` ↓ `test-shared/` NÃO tem dependências de framework ↓ Playwright carrega apenas código puro ✅ SEM CONFLITOS ``` ## Checklist ao Criar Utils [Seção intitulada “Checklist ao Criar Utils”](#checklist-ao-criar-utils) Antes de adicionar algo em `test-shared/`: * [ ] É framework-agnostic? (sem `vitest`, `@playwright/test`, `msw`) * [ ] É simples? (sem lógica complexa) * [ ] Será usado por Vitest E Playwright? * [ ] Não tem dependências externas pesadas? Se qualquer resposta for **NÃO**, coloque em: * `test-utils/` se for específico do Vitest * `e2e/` se for específico do Playwright ## Exemplos de Uso [Seção intitulada “Exemplos de Uso”](#exemplos-de-uso) ### Criar novo token fixture [Seção intitulada “Criar novo token fixture”](#criar-novo-token-fixture) test-shared/fixtures/tokens.ts ```typescript export const TOKENS = { admin: () => generateMockToken({ role: "admin" }), }; ``` ### Criar handler MSW [Seção intitulada “Criar handler MSW”](#criar-handler-msw) test-utils/handlers/templates.handlers.ts ```typescript export const templateHandlers = [ http.get("/api/templates", () => { return HttpResponse.json([createTemplate()]); }), ]; ``` ### Criar Page Object [Seção intitulada “Criar Page Object”](#criar-page-object) e2e/pages/template.page.ts ```typescript export class TemplatePage extends BasePage { async installTemplate() { ... } } ``` ## Documentação [Seção intitulada “Documentação”](#documentação) * [Utilitários Compartilhados](/frontend/testing/utils/shared) - Utils compartilhados * [Testes Playwright](/frontend/testing/e2e) - Testes Playwright * [Utils Vitest](/frontend/testing/utils/overview) - Utils Vitest (se existir) # Vitest / MSW Utilitários específicos para testes **Vitest** (unitários e de integração) usando **Mock Service Worker (MSW)** para interceptação de requisições HTTP. > **Documentação completa**: [Arquitetura de Testes](/frontend/testing/overview) **Importante**: Estes utils contêm dependências do Vitest e **NÃO devem** ser importados em testes Playwright. Use os [Utilitários Compartilhados](/frontend/testing/utils/shared) para código compartilhado. ## Estrutura [Seção intitulada “Estrutura”](#estrutura) ```plaintext test-utils/ ├── handlers/ # MSW handlers para interceptar APIs │ └── auth.ts # Handlers de autenticação ├── middlewares/ # MSW middlewares customizados ├── helpers/ # Helpers específicos do Vitest ├── fixtures/ # Fixtures específicos do Vitest ├── factories/ # Factories com dependências Vitest ├── server.ts # Setup do MSW server └── index.ts # Exportações centralizadas ``` ## Uso [Seção intitulada “Uso”](#uso) ### Setup MSW no Vitest [Seção intitulada “Setup MSW no Vitest”](#setup-msw-no-vitest) O MSW server é configurado automaticamente no `vitest.setup.tsx`: ```typescript import { beforeAll, afterAll, afterEach } from "vitest"; import { mswServer } from "./test-utils"; // Inicia o server antes de todos os testes beforeAll(() => mswServer.listen({ onUnhandledRequest: "error" })); // Reseta handlers após cada teste afterEach(() => mswServer.resetHandlers()); // Para o server após todos os testes afterAll(() => mswServer.close()); ``` ### Renderizar componente com React Query [Seção intitulada “Renderizar componente com React Query”](#renderizar-componente-com-react-query) ```typescript import { render, screen } from '@/test-utils'; import { MyComponent } from './my-component'; describe('MyComponent', () => { it('should fetch and display data', async () => { render(); // MSW intercepta automaticamente as requests expect(await screen.findByText('Data loaded')).toBeInTheDocument(); }); }); ``` ### Customizar handlers por teste [Seção intitulada “Customizar handlers por teste”](#customizar-handlers-por-teste) ```typescript import { render, screen } from '@/test-utils'; import { mswServer } from '@/test-utils'; import { http, HttpResponse } from 'msw'; describe('MyComponent with errors', () => { it('should handle API errors', async () => { // Override handler apenas para este teste mswServer.use( http.get('/api/data', () => { return HttpResponse.json({ error: 'Failed' }, { status: 500 }); }) ); render(); expect(await screen.findByText('Error occurred')).toBeInTheDocument(); }); }); ``` ## Handlers Disponíveis [Seção intitulada “Handlers Disponíveis”](#handlers-disponíveis) Os handlers estão organizados por domínio em `handlers/`: * **auth.ts**: Login, signup, logout, token refresh * *(Adicione novos handlers conforme necessário)* ## Boas Práticas [Seção intitulada “Boas Práticas”](#boas-práticas) ### ✅ Fazer [Seção intitulada “✅ Fazer”](#-fazer) ```typescript // ✅ Importar de test-utils em testes Vitest import { render, mswServer } from "@/test-utils"; import { TOKENS } from "@/test-shared"; // Shared é OK ``` ### ❌ NÃO Fazer [Seção intitulada “❌ NÃO Fazer”](#-não-fazer) ```typescript // ❌ Importar test-utils em testes Playwright import { mswServer } from "../../test-utils"; // ERRO! // ❌ Importar Page Objects do Playwright aqui import { LoginPage } from "../../e2e/pages/login"; // ERRO! ``` ## Adicionar Novos Handlers [Seção intitulada “Adicionar Novos Handlers”](#adicionar-novos-handlers) 1. Crie um arquivo em `handlers/` (ex: `handlers/user.ts`) 2. Exporte os handlers: handlers/user.ts ```typescript import { http, HttpResponse } from "msw"; export const userHandlers = [ http.get("/api/console/current-user", () => { return HttpResponse.json({ id: "user-123", email: "test@example.com", name: "Test User", }); }), http.patch("/api/console/profile", async ({ request }) => { const body = await request.json(); return HttpResponse.json({ ...body, updated: true }); }), ]; ``` 3. Registre no `server.ts`: server.ts ```typescript import { setupServer } from "msw/node"; import { authHandlers } from "./handlers/auth"; import { userHandlers } from "./handlers/user"; // Novo export const mswServer = setupServer( ...authHandlers, ...userHandlers, // Adiciona aqui ); ``` ## Troubleshooting [Seção intitulada “Troubleshooting”](#troubleshooting) ### Requests não estão sendo interceptadas [Seção intitulada “Requests não estão sendo interceptadas”](#requests-não-estão-sendo-interceptadas) **Problema**: Componente faz request mas MSW não intercepta. **Solução**: 1. Verifique se o handler está registrado no `server.ts` 2. Confirme que o URL do handler corresponde exatamente à request 3. Verifique se `mswServer.listen()` foi chamado no setup ### Erro “Cannot redefine property” [Seção intitulada “Erro “Cannot redefine property””](#erro-cannot-redefine-property) **Problema**: Erro ao rodar testes Playwright. **Solução**: Você está importando `test-utils` em testes Playwright. Use apenas `test-shared` em testes E2E. ### Handler não está sendo aplicado [Seção intitulada “Handler não está sendo aplicado”](#handler-não-está-sendo-aplicado) **Problema**: Override com `mswServer.use()` não funciona. **Solução**: * Use `mswServer.use()` ANTES de renderizar o componente * Se ainda não funcionar, use `mswServer.resetHandlers()` antes ## 📖 Links Úteis [Seção intitulada “📖 Links Úteis”](#-links-úteis) * [MSW Documentation](https://mswjs.io/) * [Vitest](https://vitest.dev/guide/) * [React Testing Library](https://testing-library.com/react) * [Arquitetura de Testes](/frontend/testing/overview) # Compartilhados > Utilitários de teste compartilhados entre Vitest e Playwright Utilitários de teste **compartilhados** e **independentes de framework**, que podem ser usados tanto nos testes do **Vitest** quanto do **Playwright**. > **Documentação completa**: [Arquitetura de Testes](/frontend/testing/overview) ## Objetivo [Seção intitulada “Objetivo”](#objetivo) Evitar conflitos entre frameworks de teste mantendo fixtures e factories sem dependências externas de teste (Vitest, Playwright, MSW, etc). ## Estrutura [Seção intitulada “Estrutura”](#estrutura) ```plaintext test-shared/ ├── fixtures/ # Dados fixos para testes │ ├── ids.ts # IDs de teste consistentes │ ├── tokens.ts # Geração de tokens mock │ └── users.ts # Usuários pré-configurados ├── factories/ # Criadores de objetos de teste │ └── user.ts # Factory de usuários └── index.ts # Exportações centralizadas ``` ## Uso [Seção intitulada “Uso”](#uso) ### Em testes do Playwright (E2E) [Seção intitulada “Em testes do Playwright (E2E)”](#em-testes-do-playwright-e2e) ```typescript import { TOKENS, createUser, TEST_IDS } from "../../test-shared"; test("should authenticate", async ({ page }) => { await page.route("**/api/login", async (route) => { await route.fulfill({ body: JSON.stringify({ access_token: TOKENS.valid(), refresh_token: TOKENS.refresh(), }), }); }); }); ``` ### Em testes do Vitest (Unit/Integration) [Seção intitulada “Em testes do Vitest (Unit/Integration)”](#em-testes-do-vitest-unitintegration) ```typescript import { TOKENS, createUser } from "../../test-shared"; describe("Auth Service", () => { it("should parse token", () => { const token = TOKENS.valid(); expect(parseToken(token)).toBeDefined(); }); }); ``` ## Disponível [Seção intitulada “Disponível”](#disponível) ### Fixtures [Seção intitulada “Fixtures”](#fixtures) * **`TOKENS`**: Gerador de tokens JWT mock * `TOKENS.valid()` - Token válido por 1h * `TOKENS.expired()` - Token expirado * `TOKENS.refresh()` - Refresh token * `TOKENS.impersonate.valid()` - Token de impersonação * **`TEST_IDS`**: IDs consistentes para testes * `TEST_IDS.users.default` - ID do usuário padrão * `TEST_IDS.accounts.default` - ID da conta padrão * **`TEST_USERS`**: Usuários pré-configurados * `TEST_USERS.default` - Usuário padrão * `TEST_USERS.admin` - Usuário admin ### Factories [Seção intitulada “Factories”](#factories) * **`createUser(overrides?)`**: Cria usuário com valores padrão ```typescript const user = createUser({ name: "Custom Name" }); ``` ## 🚫 O que NÃO colocar aqui [Seção intitulada “🚫 O que NÃO colocar aqui”](#-o-que-não-colocar-aqui) * ❌ Setups do MSW (vai em `test-utils/`) * ❌ Configurações do Vitest (vai em `vitest.config.ts`) * ❌ Page Objects do Playwright (vai em `e2e/pages/`) * ❌ Dependências externas pesadas (faker, etc) ## ✅ O que colocar aqui [Seção intitulada “✅ O que colocar aqui”](#-o-que-colocar-aqui) * ✅ Fixtures simples e estáticas * ✅ Factories sem dependências externas * ✅ Constantes de teste * ✅ Geradores de dados mock simples * ✅ Tipos compartilhados para testes ## Diferença entre `test-shared/` e `test-utils/` [Seção intitulada “Diferença entre test-shared/ e test-utils/”](#diferença-entre-test-shared-e-test-utils) | Aspecto | `test-shared/` | `test-utils/` | | ------------------ | ------------------- | ------------------- | | **Framework** | Agnóstico | Específico Vitest | | **Dependências** | Nenhuma | MSW, Vitest, etc | | **Uso** | Vitest + Playwright | Apenas Vitest | | **Conteúdo** | Fixtures, Factories | Handlers MSW, Setup | | **Importável por** | Qualquer teste | Só testes Vitest | ## Boas Práticas [Seção intitulada “Boas Práticas”](#boas-práticas) 1. **Mantenha simples**: Sem lógica complexa ou dependências externas 2. **Seja consistente**: Use sempre os mesmos IDs/valores nos testes 3. **Documente**: Adicione JSDoc explicando o propósito de cada fixture 4. **Exporte tudo**: Use `index.ts` para exportações centralizadas 5. **Evite estado**: Factories devem ser funções puras quando possível