MÓDULO 3.2

📨 Construindo o Inbox Unificado (InboxAI)

A primeira tela do projeto-âncora: três colunas, multi-canal (WhatsApp, IG, email, web), real-time mockado, atalhos de operador power-user.

6
Tópicos
60
Minutos
Inter
Nível
Build
Tipo
1

🏗️ Anatomia do Inbox

O layout de três colunas existe porque funciona em todo inbox profissional do mercado: Slack, Linear, Intercom, Superhuman, Front. Reinventar a roda aqui custa caro e o cliente estranha.

📐 Wireframe — proporções e função

Lista de Conversas Thread Ativa Contato 1 fr (~280px) 2 fr (flex) 1 fr (~280px)

📌 Por que essas proporções

Lista 1fr (~280px) cabe título + preview + timestamp. Thread 2fr é onde mora o trabalho real, então ganha mais espaço. Contato 1fr aparece e some no mobile sem dor. É proporção testada por bilhões de uso de Gmail/Slack/Linear — não tente outra.

2

🧱 Componentes

Componentização clara é o que destrava reuso, testes e iteração rápida. Sem ela, mudar o estilo de uma mensagem vira mudar 12 arquivos.

📁 Estrutura de pastas

app/
└── inbox/
    └── page.tsx                  # rota /inbox
components/
└── inbox/
    ├── ConversationList.tsx      # coluna esquerda
    ├── ConversationItem.tsx      # cada linha da lista
    ├── ChannelBadge.tsx          # 🟢 IG ✉️ 💬
    ├── MessageThread.tsx         # coluna central
    ├── MessageBubble.tsx         # cada balão
    ├── MessageComposer.tsx       # textarea + botões
    └── ContactPanel.tsx          # coluna direita
lib/
├── mock-conversations.ts         # dados fake
└── types.ts                      # Conversation, Message, Contact
store/
└── inbox-store.ts                # Zustand

⚙️ Snippet — ConversationItem

type Props = {
  conversation: Conversation;
  isActive: boolean;
  onClick: () => void;
};

export function ConversationItem({ conversation, isActive, onClick }: Props) {
  const { contact, lastMessage, channel, unreadCount } = conversation;
  return (
    <button
      onClick={onClick}
      className={`w-full flex items-start gap-3 p-3 rounded-lg
        ${isActive ? 'bg-purple-500/15' : 'hover:bg-neutral-800/50'}`}
    >
      <Avatar src={contact.avatar} alt={contact.name} />
      <div className="flex-1 min-w-0 text-left">
        <div className="flex items-center gap-2">
          <span className="font-medium truncate">{contact.name}</span>
          <ChannelBadge channel={channel} />
        </div>
        <p className="text-xs text-neutral-400 truncate">{lastMessage.preview}</p>
      </div>
      {unreadCount > 0 && (
        <span className="bg-purple-500 text-white text-xs px-2 py-0.5 rounded-full">
          {unreadCount}
        </span>
      )}
    </button>
  );
}

🎯 Regras de componentização

  • 1 componente = 1 arquivo — facilita find/replace e o agente lê melhor.
  • Props tipadas explícitas — não use any nem inline type, o Codex se perde.
  • Nada de lógica de fetch dentro — componente só apresenta; estado vem por prop ou hook.
  • Nome diz o que é, não onde está — ConversationItem, não LeftSidebarRow.
3

📊 Estado: Conversas, Mensagens, Status

Estado mal modelado é a origem da maioria dos bugs em frontend complexo. Decidir certo aqui evita reescrita de 60% do projeto na Trilha 4 quando plugar o backend de verdade.

🟣 Zustand

Store local, simples. Bom pra protótipo e pra começar.

  • ✓ 1KB, sem boilerplate
  • ✓ TypeScript-friendly
  • ✗ Real-time é manual
  • ✗ Sem persistência server-side

🌊 Convex

Real-time + DB acoplados. Bom pra produção e InboxAI completo.

  • ✓ useQuery sincroniza automaticamente
  • ✓ Mutations otimistas
  • ✓ Schema tipado end-to-end
  • ✗ Requer setup (vem na Trilha 4)

📐 Schema sugerido (TypeScript)

// lib/types.ts
export type Channel = 'whatsapp' | 'instagram' | 'email' | 'web';
export type ConversationStatus = 'open' | 'pending' | 'resolved';

export type Contact = {
  id: string;
  name: string;
  avatar?: string;
  phone?: string;
  email?: string;
  tags: string[];
};

export type Message = {
  id: string;
  conversationId: string;
  authorId: string;            // 'agent' | contactId | 'ai'
  body: string;
  createdAt: number;           // epoch ms
  readAt?: number;
};

export type Conversation = {
  id: string;
  contactId: string;
  channel: Channel;
  status: ConversationStatus;
  assignedTo?: string;         // userId do operador
  lastMessage: { preview: string; at: number };
  unreadCount: number;
  createdAt: number;
};

💡 A regra: comece em Zustand, migre pra Convex

Na Trilha 3 estamos fazendo frontend mockado — Zustand é o suficiente. Quando chegar na Trilha 4 e plugar o backend, troque useStore() por useQuery() e os tipos já estão prontos. Zero refactor de componente.

4

🔴 Real-time Mock

Mockar o real-time desde o início destrava UX inteira (animação de entrada, badge de unread, scroll automático, som) sem bloquear no backend. Quando o WebSocket real chegar, é trocar a fonte.

⏲️ Mock com setInterval

// hooks/useFakeIncomingMessages.ts
import { useEffect } from 'react';
import { useInboxStore } from '@/store/inbox-store';
import { generateFakeMessage } from '@/lib/fake-data';

export function useFakeIncomingMessages(intervalMs = 8000) {
  const addMessage = useInboxStore((s) => s.addMessage);

  useEffect(() => {
    const id = setInterval(() => {
      const msg = generateFakeMessage();
      addMessage(msg);
      // notification, scroll, badge — tudo reage automático
    }, intervalMs);
    return () => clearInterval(id);
  }, [addMessage, intervalMs]);
}

❌ Esperando o backend

  • • UI estática até semana 3
  • • Animações descobertas tarde
  • • Bug de race condition em produção
  • • Cliente não tem o que ver

✅ Mock-first

  • • UI viva desde o dia 1
  • • Edge cases descobertos cedo
  • • Cliente vê demo já em D+2
  • • Backend só pluga quando pronto

🎯 O segredo: trate como real desde o começo

A função addMessage tem que ter exatamente a mesma assinatura que terá com Convex/WebSocket. Aí, no dia da troca, você muda 1 arquivo (a fonte de dados) e tudo continua funcionando.

5

🏷️ Multi-canal: Badge Visual

Multi-canal é o diferencial central do InboxAI. Sem badge claro, o operador responde "oi" no WhatsApp como se fosse email — e perde tom. Com badge, ele lê o canal antes de qualquer letra.

🎨 Tabela de canais

Canal Ícone Cor Tom esperado
WhatsApp🟢#25D366Informal, áudio comum
Instagram DM📷#E4405FCurto, emoji-heavy
Email✉️#3B82F6Formal, longo
Web Chat💬#7C3AEDDireto ao ponto

⚙️ Snippet — ChannelBadge

const CHANNELS = {
  whatsapp:  { emoji: '🟢', label: 'WhatsApp',  className: 'bg-green-500/15 text-green-400' },
  instagram: { emoji: '📷', label: 'Instagram', className: 'bg-pink-500/15 text-pink-400' },
  email:     { emoji: '✉️', label: 'Email',     className: 'bg-blue-500/15 text-blue-400' },
  web:       { emoji: '💬', label: 'Web Chat',  className: 'bg-purple-500/15 text-purple-400' },
} as const;

export function ChannelBadge({ channel }: { channel: Channel }) {
  const c = CHANNELS[channel];
  return (
    <span className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs ${c.className}`}>
      <span>{c.emoji}</span>
      <span>{c.label}</span>
    </span>
  );
}

🌈 Cor + ícone, sempre os dois

Daltônicos representam ~8% dos homens. Cor sozinha exclui. Ícone sozinho fica fraco. Sempre os dois juntos — é uma das poucas regras que vale aplicar sem questionar.

6

⌨️ Atalhos de Teclado e UX pro Operador

Operador profissional atende 200+ conversas/dia. Cada clique a menos é minutos por dia. Atalhos transformam o produto de "ok" em "indispensável" — é o que faz o cliente cancelar a concorrência.

Tecla Ação Inspiração
j / kNavegar próxima/anterior conversaVim, Gmail, Linear
rFocar no composer e responderGmail, Front
aAtribuir conversa a alguémLinear, Intercom
eArquivar (resolved)Gmail
/Focar buscaSlack, GitHub
⌘ KCommand palette (tudo)Linear, Raycast
?Mostrar todos os atalhosGmail, GitHub

⚙️ Snippet — listener global

// hooks/useInboxKeybindings.ts
useEffect(() => {
  const handler = (e: KeyboardEvent) => {
    // não dispara se tá digitando em input/textarea
    if (e.target instanceof HTMLElement &&
        ['INPUT', 'TEXTAREA'].includes(e.target.tagName)) return;

    if (e.key === 'j') selectNext();
    if (e.key === 'k') selectPrev();
    if (e.key === 'r') focusComposer();
    if (e.key === 'e') resolveActive();
    if ((e.metaKey || e.ctrlKey) && e.key === 'k') openCommandPalette();
  };
  window.addEventListener('keydown', handler);
  return () => window.removeEventListener('keydown', handler);
}, []);

📌 A pegadinha: ignorar input/textarea

Sem o guard if (input/textarea) return, digitar "javascript" no composer arquiva 5 conversas e atribui a 2 pessoas. Esse é o bug clássico — sempre, sem exceção, ignora atalhos quando o foco está em campo de texto.

O que Aprendemos

Layout 3 colunas (1fr / 2fr / 1fr) é padrão consagrado — não reinventa, esse formato resolveu pra Slack, Linear, Intercom.
7 componentes nomeados isolam mudança — ConversationItem, MessageBubble, ContactPanel etc.
Estado começa em Zustand, migra pra Convex — assinatura igual em ambos preserva os componentes.
Real-time mockado com setInterval destrava UX viva — animações, badges e scroll prontos antes do backend existir.
Multi-canal: badge cor + ícone juntos — operador identifica em milissegundos sem ler.
Atalhos de teclado j/k/r/a/e/⌘K transformam o produto — guard para inputs é OBRIGATÓRIO.

Próximo Módulo:

3.3 — CRM Kanban + Dashboard de Métricas: pipeline com drag-and-drop, card do deal, métricas que importam, charts com Recharts.