🏗️ 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
📌 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.
🧱 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.
📊 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.
🔴 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.
🏷️ 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 |
|---|---|---|---|
| 🟢 | #25D366 | Informal, áudio comum | |
| Instagram DM | 📷 | #E4405F | Curto, emoji-heavy |
| ✉️ | #3B82F6 | Formal, longo | |
| Web Chat | 💬 | #7C3AED | Direto 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.
⌨️ 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 / k | Navegar próxima/anterior conversa | Vim, Gmail, Linear |
| r | Focar no composer e responder | Gmail, Front |
| a | Atribuir conversa a alguém | Linear, Intercom |
| e | Arquivar (resolved) | Gmail |
| / | Focar busca | Slack, GitHub |
| ⌘ K | Command palette (tudo) | Linear, Raycast |
| ? | Mostrar todos os atalhos | Gmail, 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
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.