Initial commit

- Telas iniciais do projeto criadas
- Estrutura de pastas e arquivos definida
- Componentes instalados e linguagem definida
- Vite configurado para React e build de dev rapida
- Mockups de dados criados para desenvolvimento dos módulos
- Documentação inicial criada para guiar o desenvolvimento e uso do projeto
This commit is contained in:
Rafael Alves Lopes 2026-03-19 18:22:18 -03:00
commit 8e29dde2a1
52 changed files with 5284 additions and 0 deletions

1
.gitignore vendored Normal file
View File

@ -0,0 +1 @@
node_modules

12
Dockerfile Normal file
View File

@ -0,0 +1,12 @@
FROM node:lts
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
EXPOSE 3000
CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0", "--port", "3000"]

BIN
dist/assets/favicon_blue-CzkOczz3.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

68
dist/assets/index-1xjqdjIG.js vendored Normal file

File diff suppressed because one or more lines are too long

1
dist/assets/index-BsY34Fgu.css vendored Normal file
View File

@ -0,0 +1 @@
:root{font-family:Segoe UI,Helvetica Neue,sans-serif;color:#122230;background:radial-gradient(circle at top left,rgba(0,164,183,.12),transparent 28%),radial-gradient(circle at bottom right,rgba(229,162,42,.14),transparent 24%),#f5f8fb;color-scheme:light;--color-primary: #003150;--color-secondary: #b51f1f;--color-accent: #00a4b7;--color-highlight: #e5a22a;--color-surface: rgba(255, 255, 255, .9);--color-surface-strong: #ffffff;--color-text: #122230;--color-text-soft: #5e6d7b;--color-border: rgba(0, 49, 80, .12);--shadow-lg: 0 24px 60px rgba(0, 49, 80, .12);--shadow-md: 0 12px 28px rgba(0, 49, 80, .08)}*{box-sizing:border-box}html,body,#root{min-height:100%;margin:0}body{min-height:100vh}body,button,input{font:inherit}button{cursor:pointer}a{color:inherit;text-decoration:none}

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

BIN
dist/assets/logo_white_mode-BIHgqUPv.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

14
dist/index.html vendored Normal file
View File

@ -0,0 +1,14 @@
<!doctype html>
<html lang="pt-BR">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" type="image/png" href="/assets/favicon_blue-CzkOczz3.png" />
<title>Omnichannel Sothis</title>
<script type="module" crossorigin src="/assets/index-1xjqdjIG.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-BsY34Fgu.css">
</head>
<body>
<div id="root"></div>
</body>
</html>

13
docs/README.md Normal file
View File

@ -0,0 +1,13 @@
# Documentação do Frontend
Esta pasta reúne a documentação funcional e conceitual do frontend MVP Omnichannel.
## Índice
- [Visão Geral](./visao-geral.md)
- [Módulo Auth / Login](./modulo-auth.md)
- [Módulo Home / Dashboard](./modulo-home.md)
- [Módulo Chat](./modulo-chat.md)
- [Módulo Call](./modulo-call.md)
- [Módulo Attendance / Novo Atendimento](./modulo-attendance.md)
- [Casos de Uso em Formato RPG](./casos-de-uso-rpg.md)

105
docs/casos-de-uso-rpg.md Normal file
View File

@ -0,0 +1,105 @@
# Casos de Uso em Formato RPG
## Introdução
Imagine o reino de **Sharvus**, onde toda vila, fortaleza e guilda depende de mensagens rápidas para manter ordem, comércio e confiança com seus cidadãos.
No centro desse reino existe a fortaleza da **Ordem de Sothis**, onde trabalham os guerreiros do suporte, os mensageiros do comercial e os guardiões do financeiro.
Cada atendimento é uma missão.
Cada cliente é um personagem importante.
Cada tela do sistema é uma parte da jornada.
## O Herói
Nosso herói é **Aren**, um guerreiro de suporte da Ordem de Sothis.
Sua missão não é derrotar monstros, mas resolver problemas antes que eles virem caos no reino.
Para isso, ele usa o grande portal chamado **Omnichannel**.
## Capítulo 1: O Portal de Entrada
Aren chega ao salão principal e encontra o **Portal de Login**.
Ali ele:
- informa suas credenciais
- entra no sistema
- acessa o centro de comando
Na prática, este é o caso de uso de autenticação visual do módulo `auth`.
## Capítulo 2: O Mapa da Operação
Ao entrar, Aren vê o grande mapa do reino: a **Home / Dashboard**.
Nesse mapa ele consegue:
- ver conversas ativas
- trocar entre mensagens e ligações
- buscar contatos
- iniciar novas missões
Na prática, este é o caso de uso central do módulo `home`.
## Capítulo 3: A Mensagem do Cidadão
Uma cidadã chamada **Maria Souza** envia um pedido urgente por WhatsApp.
Aren abre a conversa no módulo `chat` e pode:
- ler o histórico
- responder rapidamente
- acompanhar novas mensagens
- transferir o caso para outra guilda, como Financeiro ou Comercial
Na prática, este módulo representa o caso de uso de atendimento textual em tempo real.
## Capítulo 4: O Chamado por Voz
Nem toda missão pode ser resolvida por pergaminhos e mensagens.
Às vezes, o cidadão precisa ouvir a voz de alguém da Ordem.
Então Aren inicia uma ligação no módulo `call`, onde ele:
- visualiza quem está na chamada
- acompanha o tempo da conversa
- usa controles de chamada
- encerra o contato quando a missão termina
Na prática, este módulo representa o caso de uso de atendimento por voz.
## Capítulo 5: A Missão Começa Aqui
Antes de qualquer conversa, Aren pode abrir o módulo `attendance` para iniciar uma nova missão.
Ele escolhe:
- quem será atendido
- qual canal usar
- para qual área o caso deve ir
Depois disso:
- se for mensagem, ele segue para o chat
- se for voz, ele segue para a chamada
Na prática, este módulo representa o caso de uso de abertura rápida de atendimento.
## Moral da História
O Omnichannel é a mesa de guerra de Sharvus.
Ele permite que um único guerreiro:
- veja o cenário
- escolha o canal
- converse com o cidadão
- transfira a missão
- resolva o problema com agilidade
Em linguagem de produto, o sistema mostra como centralizar operação, comunicação e contexto em uma experiência única.

35
docs/modulo-attendance.md Normal file
View File

@ -0,0 +1,35 @@
# Módulo Attendance / Novo Atendimento
## Objetivo
Permitir que um operador inicie rapidamente um novo atendimento.
## Tela principal
- `NewAttendancePage.jsx`
## Componentes e lógica
- `RecentContactsList.jsx`: contatos recentes e seleção rápida
- `attendanceMocks.js`: canais, áreas e contatos mockados
## Funcionalidades simuladas
- buscar contato
- escolher um contato recente
- informar novo número
- escolher canal:
- WhatsApp
- SMS
- Ligação
- selecionar área opcional
- iniciar atendimento
## Regras de navegação
- se o canal escolhido for `WhatsApp` ou `SMS`, a navegação vai para `/chat`
- se o canal escolhido for `Ligação`, a navegação vai para `/call`
## Papel na apresentação
Esse módulo deixa muito claro o ganho operacional do produto: o atendente consegue iniciar fluxos rapidamente sem sair da mesma plataforma.

33
docs/modulo-auth.md Normal file
View File

@ -0,0 +1,33 @@
# Módulo Auth / Login
## Objetivo
Apresentar uma entrada elegante e moderna para o produto, simulando autenticação corporativa sem backend real.
## Tela principal
- `LoginPage.jsx`
## Componentes e lógica
- `LoginForm.jsx`: formulário visual de acesso
- `useLogin.js`: controla o envio e redirecionamento
- `authService.js`: mock de autenticação
## Fluxo
1. O usuário informa usuário e senha
2. Clica em `Entrar`
3. O login mock é executado
4. O usuário é redirecionado para `/home`
## Elementos importantes
- branding Sothis
- botão `Entrar com Microsoft`
- link `Esqueci minha senha`
- visual de produto SaaS
## Papel na apresentação
A tela de login serve como entrada institucional do MVP e ajuda a criar percepção de maturidade do produto logo no primeiro contato.

32
docs/modulo-call.md Normal file
View File

@ -0,0 +1,32 @@
# Módulo Call
## Objetivo
Simular uma ligação ativa em modo softphone, com visual de operação em tempo real.
## Tela principal
- `CallPage.jsx`
## Componentes e lógica
- `CallHeader.jsx`: contexto superior e retorno para home
- `CallControls.jsx`: controles visuais da chamada
- `useCallTimer.js`: timer automático
- `callMocks.js`: dados do cliente e controles
## Funcionalidades simuladas
- contagem automática do tempo de chamada
- exibição do cliente ativo
- avatar e fila de atendimento
- controles:
- mudo
- teclado
- alto-falante
- transferir
- encerrar chamada
## Papel na apresentação
Este módulo mostra que o produto não é apenas mensageria, mas também cobre voz dentro da mesma proposta omnichannel.

37
docs/modulo-chat.md Normal file
View File

@ -0,0 +1,37 @@
# Módulo Chat
## Objetivo
Simular um atendimento em tempo real com aparência próxima de um produto de operação real.
## Tela principal
- `ChatPage.jsx`
## Componentes e lógica
- `ChatConversationList.jsx`: lista de contatos e canais
- `ChatWindow.jsx`: header, mensagens e input
- `ChatTransferPanel.jsx`: fluxo visual de transferência
- `useChat.js`: estado do chat, envio e resposta simulada
- `chatMocks.js`: contatos, áreas, atendentes e mensagens iniciais
## Funcionalidades simuladas
- alternar entre conversas
- enviar mensagem
- receber resposta mock automática
- rolagem automática
- transferência para outra área
- escolha de atendente de destino
- observação opcional na transferência
## Canais representados
- WhatsApp
- SMS
- Email
## Papel na apresentação
O módulo de chat demonstra como a plataforma concentra diferentes canais em uma única experiência operacional.

40
docs/modulo-home.md Normal file
View File

@ -0,0 +1,40 @@
# Módulo Home / Dashboard
## Objetivo
Ser o hub central do atendente após o login.
## Tela principal
- `HomePage.jsx`
## Componentes e lógica
- `HomeTopbar.jsx`: busca, toggle de abas, avatar e contexto superior
- `HomeSidebar.jsx`: navegação lateral e botão de novo atendimento
- `MessagesWorkspace.jsx`: lista de conversas, chat resumido e painel de ações
- `CallsWorkspace.jsx`: visão resumida das ligações
- `homeMocks.js`: dados mockados do dashboard
## Funcionalidades simuladas
- trocar entre `Mensagens` e `Ligações`
- buscar conversas mockadas
- abrir rota de chat
- abrir rota de call
- abrir rota de novo atendimento
## Papel na apresentação
É a tela que melhor comunica o conceito do produto:
- múltiplos canais
- visão operacional única
- agilidade no atendimento
- sensação de plataforma pronta para uso
## Pontos de UX
- responsividade adaptada para mobile, tablet, desktop e desktop largo
- sidebar separada do conteúdo para leitura mais clara
- cards de contexto e indicadores operacionais

83
docs/visao-geral.md Normal file
View File

@ -0,0 +1,83 @@
# Visão Geral do Projeto
## Objetivo
O projeto representa o frontend MVP do Omnichannel Sothis.
O objetivo principal é demonstrar, de forma visual e convincente, como um atendente pode:
- entrar na plataforma
- visualizar atendimentos e conversas
- iniciar um novo atendimento
- conversar com clientes em canais diferentes
- simular uma ligação ativa
## Diretriz do MVP
Este MVP prioriza percepção de produto acabado.
Ou seja:
- os fluxos parecem reais
- os dados são mockados
- a navegação existe
- a experiência é pensada para demonstração, validação e apresentação
## Stack
- React
- Vite
- JavaScript
- React Router
- CSS via estilos modernos em componentes
## Estrutura
O frontend foi organizado por módulos, seguindo uma abordagem feature-based:
- `auth`
- `home`
- `chat`
- `call`
- `attendance`
Cada módulo concentra suas páginas, componentes, hooks e services mockados.
## Rotas atuais
- `/login`
- `/home`
- `/chat`
- `/call`
- `/new-attendance`
## Módulos
### Auth
Simula autenticação e entrada no sistema.
### Home
É a central do operador, com dashboard, conversas, atalhos e navegação fake para os fluxos principais.
### Chat
Simula atendimento em tempo real com mensagens, transferência e respostas automáticas mockadas.
### Call
Simula uma ligação ativa com timer automático e controles visuais de softphone.
### Attendance
Permite iniciar rapidamente um novo atendimento, escolhendo contato, canal e área.
## Público da documentação
Esta documentação serve para:
- apresentação de produto
- onboarding técnico
- alinhamento entre frontend, backend e deploy
- futura evolução para integração real

13
index.html Normal file
View File

@ -0,0 +1,13 @@
<!doctype html>
<html lang="pt-BR">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" type="image/png" href="/src/shared/assets/favicon_blue.png" />
<title>Omnichannel Sothis</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>

1719
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

20
package.json Normal file
View File

@ -0,0 +1,20 @@
{
"name": "omnichannel-frontend",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview --host 0.0.0.0 --port 3000"
},
"dependencies": {
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.30.1"
},
"devDependencies": {
"@vitejs/plugin-react": "^4.3.3",
"vite": "^5.4.10"
}
}

11
src/main.jsx Normal file
View File

@ -0,0 +1,11 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import { RouterProvider } from 'react-router-dom';
import { router } from './routes/router';
import './shared/styles/global.css';
ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode>
<RouterProvider router={router} />
</React.StrictMode>,
);

View File

@ -0,0 +1,80 @@
export function RecentContactsList({
contacts,
activeContactId,
onSelectContact,
selectedChannel,
}) {
return (
<aside
style={{
background: '#fff',
border: '1px solid var(--color-border)',
borderRadius: '28px',
padding: '1.25rem',
display: 'grid',
gap: '0.9rem',
}}
>
<div>
<strong style={{ display: 'block', fontSize: '1.08rem' }}>Contatos recentes</strong>
<span style={{ color: 'var(--color-text-soft)' }}>
Reaproveite ultimos atendimentos para ganhar velocidade.
</span>
</div>
<div style={{ display: 'grid', gap: '0.75rem' }}>
{contacts.map((contact) => {
const isActive = contact.id === activeContactId;
const isPreferred = selectedChannel === 'call'
? contact.channel === 'Ligacao'
: selectedChannel === 'sms'
? contact.channel === 'SMS'
: contact.channel === 'WhatsApp';
return (
<button
key={contact.id}
type="button"
onClick={() => onSelectContact(contact.id)}
style={{
border: '1px solid',
borderColor: isActive ? 'rgba(0, 164, 183, 0.26)' : 'var(--color-border)',
background: isActive ? 'rgba(0, 164, 183, 0.08)' : '#fff',
borderRadius: '20px',
padding: '1rem',
textAlign: 'left',
display: 'grid',
gap: '0.5rem',
}}
>
<div style={{ display: 'flex', justifyContent: 'space-between', gap: '1rem' }}>
<strong>{contact.name}</strong>
{isPreferred ? (
<span
style={{
padding: '0.2rem 0.5rem',
borderRadius: 999,
background: 'rgba(0, 164, 183, 0.12)',
color: 'var(--color-primary)',
fontSize: '0.76rem',
fontWeight: 700,
}}
>
Sugerido
</span>
) : null}
</div>
<span style={{ color: 'var(--color-text-soft)' }}>{contact.phone}</span>
<div style={{ display: 'flex', justifyContent: 'space-between', gap: '1rem' }}>
<span style={{ color: 'var(--color-primary)', fontWeight: 700 }}>{contact.channel}</span>
<span style={{ color: 'var(--color-text-soft)', fontSize: '0.85rem' }}>
{contact.lastContact}
</span>
</div>
</button>
);
})}
</div>
</aside>
);
}

View File

@ -0,0 +1,340 @@
import { useMemo, useState } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { BrandMark } from '../../../shared/components/BrandMark';
import { useViewport } from '../../../shared/hooks/useViewport';
import { RecentContactsList } from '../components/RecentContactsList';
import {
attendanceAreas,
attendanceChannels,
recentContacts,
} from '../services/attendanceMocks';
export function NewAttendancePage() {
const navigate = useNavigate();
const { isWideDesktop, isDesktop, isTablet, isMobile } = useViewport();
const [searchValue, setSearchValue] = useState('');
const [selectedChannelId, setSelectedChannelId] = useState('whatsapp');
const [selectedArea, setSelectedArea] = useState('');
const [selectedContactId, setSelectedContactId] = useState(recentContacts[0].id);
const [customNumber, setCustomNumber] = useState('');
const search = searchValue.trim().toLowerCase();
const filteredContacts = useMemo(() => {
if (!search) {
return recentContacts;
}
return recentContacts.filter((contact) => {
const haystack = `${contact.name} ${contact.phone} ${contact.channel}`.toLowerCase();
return haystack.includes(search);
});
}, [search]);
const selectedContact =
filteredContacts.find((contact) => contact.id === selectedContactId) ||
recentContacts.find((contact) => contact.id === selectedContactId) ||
recentContacts[0];
const selectedChannel =
attendanceChannels.find((channel) => channel.id === selectedChannelId) || attendanceChannels[0];
const gridTemplateColumns = isMobile
? '1fr'
: isWideDesktop
? 'minmax(300px, 360px) minmax(0, 1fr)'
: isDesktop || isTablet
? 'minmax(280px, 340px) minmax(0, 1fr)'
: '1fr';
function handleStartAttendance() {
navigate(selectedChannel.route);
}
return (
<main style={{ minHeight: '100vh', padding: '1.5rem' }}>
<section
style={{
width: 'min(1680px, calc(100vw - 3rem))',
margin: '0 auto',
background: 'var(--color-surface-strong)',
borderRadius: '32px',
boxShadow: 'var(--shadow-lg)',
padding: '1.5rem',
display: 'grid',
gap: '1.25rem',
}}
>
<header
style={{
display: 'grid',
gridTemplateColumns: isMobile ? '1fr' : 'auto 1fr auto',
gap: '1rem',
alignItems: 'center',
}}
>
<BrandMark />
<div
style={{
justifySelf: isMobile ? 'stretch' : 'center',
padding: '0.85rem 1rem',
borderRadius: '18px',
background: 'rgba(0, 49, 80, 0.06)',
color: 'var(--color-primary)',
fontWeight: 700,
textAlign: 'center',
}}
>
Criacao rapida de atendimento
</div>
<Link
to="/home"
style={{
justifySelf: isMobile ? 'stretch' : 'end',
borderRadius: '16px',
padding: '0.85rem 1rem',
background: 'var(--color-primary)',
color: '#fff',
fontWeight: 700,
textAlign: 'center',
}}
>
Voltar para home
</Link>
</header>
<section
style={{
display: 'grid',
gridTemplateColumns,
gap: '1rem',
alignItems: 'start',
}}
>
<RecentContactsList
contacts={filteredContacts}
activeContactId={selectedContact.id}
onSelectContact={setSelectedContactId}
selectedChannel={selectedChannelId}
/>
<section
style={{
background: '#fff',
border: '1px solid var(--color-border)',
borderRadius: '28px',
padding: '1.5rem',
display: 'grid',
gap: '1.25rem',
}}
>
<div>
<strong style={{ display: 'block', fontSize: '1.18rem' }}>Novo atendimento</strong>
<p style={{ margin: '0.45rem 0 0', color: 'var(--color-text-soft)', lineHeight: 1.6 }}>
Escolha o contato, o canal e a area opcional antes de iniciar. O fluxo e mockado
e leva voce direto para chat ou ligacao.
</p>
</div>
<div
style={{
display: 'grid',
gridTemplateColumns: isMobile ? '1fr' : 'minmax(0, 1fr) auto',
gap: '0.85rem',
}}
>
<input
type="search"
value={searchValue}
onChange={(event) => setSearchValue(event.target.value)}
placeholder="Buscar contato por nome ou numero"
style={{
border: '1px solid var(--color-border)',
borderRadius: '18px',
padding: '0.95rem 1rem',
background: '#fff',
outline: 'none',
}}
/>
<button
type="button"
onClick={() => setCustomNumber(selectedContact.phone)}
style={{
border: '1px solid var(--color-border)',
borderRadius: '18px',
padding: '0.95rem 1rem',
background: '#fff',
color: 'var(--color-primary)',
fontWeight: 700,
}}
>
Novo numero
</button>
</div>
<div
style={{
display: 'grid',
gridTemplateColumns: isMobile ? '1fr' : 'repeat(3, minmax(0, 1fr))',
gap: '0.85rem',
}}
>
{attendanceChannels.map((channel) => {
const isActive = channel.id === selectedChannelId;
return (
<button
key={channel.id}
type="button"
onClick={() => setSelectedChannelId(channel.id)}
style={{
border: '1px solid',
borderColor: isActive ? `${channel.accent}44` : 'var(--color-border)',
borderRadius: '22px',
padding: '1rem',
background: isActive ? `${channel.accent}12` : '#fff',
textAlign: 'left',
display: 'grid',
gap: '0.45rem',
}}
>
<strong style={{ color: isActive ? channel.accent : 'var(--color-text)' }}>
{channel.label}
</strong>
<span style={{ color: 'var(--color-text-soft)' }}>
{channel.id === 'call'
? 'Inicia uma ligacao mock em tela cheia.'
: 'Abre o fluxo de conversa em tempo real.'}
</span>
</button>
);
})}
</div>
<div
style={{
display: 'grid',
gridTemplateColumns: isMobile ? '1fr' : 'repeat(2, minmax(0, 1fr))',
gap: '1rem',
}}
>
<label style={{ display: 'grid', gap: '0.45rem' }}>
<span style={{ fontWeight: 600 }}>Area (opcional)</span>
<select
value={selectedArea}
onChange={(event) => setSelectedArea(event.target.value)}
style={{
border: '1px solid var(--color-border)',
borderRadius: '18px',
padding: '0.95rem 1rem',
background: '#fff',
outline: 'none',
}}
>
<option value="">Selecionar depois</option>
{attendanceAreas.map((area) => (
<option key={area} value={area}>
{area}
</option>
))}
</select>
</label>
<label style={{ display: 'grid', gap: '0.45rem' }}>
<span style={{ fontWeight: 600 }}>Numero selecionado</span>
<input
type="text"
value={customNumber || selectedContact.phone}
onChange={(event) => setCustomNumber(event.target.value)}
placeholder="+55 11 99999-9999"
style={{
border: '1px solid var(--color-border)',
borderRadius: '18px',
padding: '0.95rem 1rem',
background: '#fff',
outline: 'none',
}}
/>
</label>
</div>
<section
style={{
display: 'grid',
gridTemplateColumns: isMobile ? '1fr' : 'minmax(0, 1.1fr) minmax(280px, 0.9fr)',
gap: '1rem',
}}
>
<article
style={{
borderRadius: '24px',
padding: '1.25rem',
background:
'linear-gradient(135deg, rgba(0, 49, 80, 0.98), rgba(11, 90, 134, 0.94))',
color: '#fff',
display: 'grid',
gap: '0.7rem',
}}
>
<span
style={{
width: 'fit-content',
padding: '0.35rem 0.75rem',
borderRadius: '999px',
background: 'rgba(255, 255, 255, 0.12)',
fontWeight: 700,
fontSize: '0.84rem',
}}
>
Resumo do fluxo
</span>
<strong style={{ fontSize: '1.25rem' }}>{selectedContact.name}</strong>
<span style={{ color: 'rgba(255, 255, 255, 0.74)' }}>
Canal escolhido: {selectedChannel.label}
</span>
<span style={{ color: 'rgba(255, 255, 255, 0.74)' }}>
Numero: {customNumber || selectedContact.phone}
</span>
<span style={{ color: 'rgba(255, 255, 255, 0.74)' }}>
Area: {selectedArea || 'Definir depois'}
</span>
</article>
<article
style={{
borderRadius: '24px',
padding: '1.25rem',
border: '1px solid var(--color-border)',
background: '#fff',
display: 'grid',
gap: '0.7rem',
}}
>
<strong>Proxima rota</strong>
<span style={{ color: 'var(--color-text-soft)' }}>
{selectedChannel.route === '/call'
? 'Ao iniciar, voce vai para a tela de ligacao.'
: 'Ao iniciar, voce vai para a tela de chat.'}
</span>
<button
type="button"
onClick={handleStartAttendance}
style={{
border: 'none',
borderRadius: '18px',
padding: '1rem 1.1rem',
background: 'linear-gradient(135deg, var(--color-primary), #0b5a86)',
color: '#fff',
fontWeight: 800,
marginTop: '0.4rem',
}}
>
Iniciar atendimento
</button>
</article>
</section>
</section>
</section>
</section>
</main>
);
}

View File

@ -0,0 +1,38 @@
export const attendanceChannels = [
{ id: 'whatsapp', label: 'WhatsApp', route: '/chat', accent: '#2bb741' },
{ id: 'sms', label: 'SMS', route: '/chat', accent: '#00a4b7' },
{ id: 'call', label: 'Ligacao', route: '/call', accent: '#e5a22a' },
];
export const attendanceAreas = ['Suporte', 'Financeiro', 'Comercial'];
export const recentContacts = [
{
id: 'maria-souza',
name: 'Maria Souza',
phone: '+55 11 99888-7766',
channel: 'WhatsApp',
lastContact: 'Hoje, 09:42',
},
{
id: 'empresa-alpha',
name: 'Empresa Alpha',
phone: '+55 11 4002-2020',
channel: 'Email',
lastContact: 'Ontem, 16:18',
},
{
id: 'joao-pedro',
name: 'Joao Pedro',
phone: '+55 31 98877-1102',
channel: 'SMS',
lastContact: 'Hoje, 08:15',
},
{
id: 'beatriz-lima',
name: 'Beatriz Lima',
phone: '+55 21 99701-4455',
channel: 'Ligacao',
lastContact: 'Hoje, 07:51',
},
];

View File

@ -0,0 +1,97 @@
import { useState } from 'react';
import { useLogin } from '../hooks/useLogin';
const fieldStyle = {
width: '100%',
border: '1px solid var(--color-border)',
borderRadius: '16px',
padding: '0.95rem 1rem',
background: '#fff',
color: 'var(--color-text)',
outline: 'none',
};
const primaryButtonStyle = {
width: '100%',
padding: '0.95rem 1rem',
borderRadius: '16px',
border: 'none',
background: 'linear-gradient(135deg, var(--color-primary), #0b5a86)',
color: '#fff',
fontWeight: 700,
boxShadow: 'var(--shadow-md)',
};
const secondaryButtonStyle = {
width: '100%',
padding: '0.95rem 1rem',
borderRadius: '16px',
border: '1px solid rgba(0, 49, 80, 0.16)',
background: '#fff',
color: 'var(--color-primary)',
fontWeight: 700,
};
const initialFormData = {
username: '',
password: '',
};
export function LoginForm() {
const [formData, setFormData] = useState(initialFormData);
const { login, isSubmitting } = useLogin();
async function handleSubmit(event) {
event.preventDefault();
await login(formData);
}
return (
<form onSubmit={handleSubmit} style={{ display: 'grid', gap: '1rem' }}>
<label style={{ display: 'grid', gap: '0.5rem' }}>
<span style={{ fontWeight: 600 }}>Usuario AD</span>
<input
style={fieldStyle}
type="text"
placeholder="seu.usuario"
value={formData.username}
onChange={(event) =>
setFormData((current) => ({ ...current, username: event.target.value }))
}
/>
</label>
<label style={{ display: 'grid', gap: '0.5rem' }}>
<span style={{ fontWeight: 600 }}>Senha</span>
<input
style={fieldStyle}
type="password"
placeholder="Digite sua senha"
value={formData.password}
onChange={(event) =>
setFormData((current) => ({ ...current, password: event.target.value }))
}
/>
</label>
<button style={primaryButtonStyle} type="submit" disabled={isSubmitting}>
{isSubmitting ? 'Entrando...' : 'Entrar'}
</button>
<button style={secondaryButtonStyle} type="button">
Entrar com Microsoft
</button>
<a
href="#forgot-password"
style={{
justifySelf: 'center',
color: 'var(--color-secondary)',
fontWeight: 600,
}}
>
Esqueci minha senha
</a>
</form>
);
}

View File

@ -0,0 +1,24 @@
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { mockLogin } from '../services/authService';
export function useLogin() {
const navigate = useNavigate();
const [isSubmitting, setIsSubmitting] = useState(false);
async function login() {
setIsSubmitting(true);
try {
await mockLogin();
navigate('/home');
} finally {
setIsSubmitting(false);
}
}
return {
isSubmitting,
login,
};
}

View File

@ -0,0 +1,161 @@
import { LoginForm } from '../components/LoginForm';
import { BrandMark } from '../../../shared/components/BrandMark';
export function LoginPage() {
return (
<main
style={{
minHeight: '100vh',
display: 'grid',
placeItems: 'center',
padding: '2rem',
}}
>
<section
style={{
width: 'min(100%, 1080px)',
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(320px, 1fr))',
gap: '1.5rem',
alignItems: 'stretch',
}}
>
<article
style={{
background:
'linear-gradient(180deg, rgba(0, 49, 80, 0.98) 0%, rgba(11, 90, 134, 0.95) 100%)',
color: '#fff',
borderRadius: '32px',
padding: '2.5rem',
boxShadow: 'var(--shadow-lg)',
position: 'relative',
overflow: 'hidden',
display: 'grid',
gap: '1.5rem',
alignContent: 'space-between',
minHeight: '540px',
}}
>
<div
aria-hidden="true"
style={{
position: 'absolute',
inset: 0,
background:
'radial-gradient(circle at top right, rgba(229, 162, 42, 0.3), transparent 30%), radial-gradient(circle at bottom left, rgba(0, 164, 183, 0.22), transparent 35%)',
}}
/>
<div style={{ position: 'relative', display: 'grid', gap: '1.5rem' }}>
<BrandMark theme="dark" />
<div>
<p
style={{
textTransform: 'uppercase',
letterSpacing: '0.12em',
fontWeight: 700,
color: 'rgba(255, 255, 255, 0.7)',
margin: 0,
}}
>
MVP de atendimento
</p>
<h1
style={{
margin: '0.75rem 0',
fontSize: 'clamp(2.2rem, 4vw, 3.4rem)',
lineHeight: 1.05,
}}
>
Conecte-se com seu cliente em uma unica tela.
</h1>
<p
style={{
margin: 0,
maxWidth: '34rem',
color: 'rgba(255, 255, 255, 0.78)',
fontSize: '1.05rem',
}}
>
Acesse a exeriência all in one e acompanhe o produto pensado para facilitar o contato no dia a dia.
</p>
</div>
</div>
<div
style={{
position: 'relative',
display: 'grid',
gridTemplateColumns: 'repeat(3, minmax(0, 1fr))',
gap: '0.75rem',
}}
>
{[
{ label: 'Canais', value: 'WhatsApp, SMS e Voz' },
{ label: 'Fila', value: 'Distribuicao rapida' },
{ label: 'UX', value: 'Padrao SaaS responsivo' },
].map((item) => (
<div
key={item.label}
style={{
padding: '1rem',
borderRadius: '20px',
background: 'rgba(255, 255, 255, 0.1)',
backdropFilter: 'blur(10px)',
}}
>
<div style={{ fontSize: '0.8rem', opacity: 0.7 }}>{item.label}</div>
<strong style={{ display: 'block', marginTop: '0.35rem' }}>{item.value}</strong>
</div>
))}
</div>
</article>
<article
style={{
background: 'var(--color-surface)',
backdropFilter: 'blur(12px)',
border: '1px solid rgba(255, 255, 255, 0.65)',
borderRadius: '32px',
padding: '2.5rem',
boxShadow: 'var(--shadow-lg)',
display: 'grid',
gap: '1.75rem',
alignContent: 'center',
}}
>
<div style={{ display: 'grid', gap: '0.75rem' }}>
<span
style={{
width: 'fit-content',
padding: '0.4rem 0.8rem',
borderRadius: 999,
background: 'rgba(0, 164, 183, 0.12)',
color: 'var(--color-primary)',
fontWeight: 700,
fontSize: '0.85rem',
}}
>
Login seguro
</span>
<div>
<h2 style={{ margin: 0, fontSize: '2rem' }}>Entrar na plataforma</h2>
<p
style={{
margin: '0.75rem 0 0',
color: 'var(--color-text-soft)',
lineHeight: 1.6,
}}
>
Use seu usuario corporativo para acessar o MVP. A autenticacao e mockada
nesta etapa e leva voce diretamente para a dashboard principal.
</p>
</div>
</div>
<LoginForm />
</article>
</section>
</main>
);
}

View File

@ -0,0 +1,11 @@
const networkDelay = 450;
export async function mockLogin() {
await new Promise((resolve) => window.setTimeout(resolve, networkDelay));
return {
id: 'agent-001',
name: 'Ana Camolesi',
email: 'ana.camolesi@sothis.local',
};
}

View File

@ -0,0 +1,114 @@
function ControlIcon({ id }) {
const commonProps = {
width: 22,
height: 22,
viewBox: '0 0 24 24',
fill: 'none',
stroke: 'currentColor',
strokeWidth: 1.8,
strokeLinecap: 'round',
strokeLinejoin: 'round',
};
if (id === 'mute') {
return (
<svg {...commonProps}>
<path d="M11 5a2 2 0 0 1 4 0v5a2 2 0 0 1-4 0z" />
<path d="M19 10a7 7 0 0 1-.6 2.8" />
<path d="M5.6 5.6 18.4 18.4" />
<path d="M9 10a3 3 0 0 0 4.7 2.5" />
<path d="M12 19v3" />
<path d="M8 22h8" />
</svg>
);
}
if (id === 'keypad') {
return (
<svg {...commonProps}>
<rect x="4" y="4" width="4" height="4" rx="1" />
<rect x="10" y="4" width="4" height="4" rx="1" />
<rect x="16" y="4" width="4" height="4" rx="1" />
<rect x="4" y="10" width="4" height="4" rx="1" />
<rect x="10" y="10" width="4" height="4" rx="1" />
<rect x="16" y="10" width="4" height="4" rx="1" />
<rect x="4" y="16" width="4" height="4" rx="1" />
<rect x="10" y="16" width="4" height="4" rx="1" />
<rect x="16" y="16" width="4" height="4" rx="1" />
</svg>
);
}
if (id === 'speaker') {
return (
<svg {...commonProps}>
<path d="M5 9v6h4l5 4V5l-5 4z" />
<path d="M18 9a4 4 0 0 1 0 6" />
<path d="M20.5 6.5a7.5 7.5 0 0 1 0 11" />
</svg>
);
}
return (
<svg {...commonProps}>
<path d="M16 3h5v5" />
<path d="M21 3l-7 7" />
<path d="M8 7H3v5" />
<path d="M3 12l7-7" />
<path d="M16 21h5v-5" />
<path d="M21 21l-7-7" />
</svg>
);
}
export function CallControls({ controls, isMobile = false }) {
return (
<div
style={{
display: 'grid',
gridTemplateColumns: isMobile
? 'repeat(2, minmax(0, 1fr))'
: 'repeat(4, minmax(120px, 1fr))',
gap: '1rem',
}}
>
{controls.map((control) => (
<button
key={control.id}
type="button"
style={{
border: '1px solid rgba(255, 255, 255, 0.12)',
borderRadius: '24px',
padding: '1rem',
background: 'rgba(255, 255, 255, 0.06)',
color: '#fff',
display: 'grid',
gap: '0.35rem',
justifyItems: 'start',
textAlign: 'left',
}}
>
<span
aria-hidden="true"
style={{
width: 48,
height: 48,
borderRadius: '16px',
background: 'rgba(255, 255, 255, 0.1)',
display: 'grid',
placeItems: 'center',
fontWeight: 800,
color: '#fff',
}}
>
<ControlIcon id={control.id} />
</span>
<strong>{control.label}</strong>
<span style={{ color: 'rgba(255, 255, 255, 0.66)', fontSize: '0.9rem' }}>
{control.hint}
</span>
</button>
))}
</div>
);
}

View File

@ -0,0 +1,56 @@
import { Link } from 'react-router-dom';
export function CallHeader({ isMobile = false }) {
return (
<header
style={{
display: 'grid',
gridTemplateColumns: isMobile ? '1fr' : 'auto 1fr auto',
alignItems: 'center',
gap: '1rem',
}}
>
<div
style={{
justifySelf: isMobile ? 'stretch' : 'start',
padding: '0.65rem 0.95rem',
borderRadius: '999px',
background: 'rgba(255, 255, 255, 0.08)',
color: 'rgba(255, 255, 255, 0.84)',
fontWeight: 700,
textAlign: 'center',
}}
>
Ligacao ativa
</div>
<div
style={{
textAlign: 'center',
color: 'rgba(255, 255, 255, 0.68)',
letterSpacing: '0.12em',
textTransform: 'uppercase',
fontSize: '0.8rem',
fontWeight: 700,
}}
>
Softphone MVP Omnichannel
</div>
<Link
to="/home"
style={{
justifySelf: isMobile ? 'stretch' : 'end',
borderRadius: '16px',
padding: '0.85rem 1rem',
background: 'rgba(255, 255, 255, 0.08)',
color: '#fff',
fontWeight: 700,
textAlign: 'center',
}}
>
Voltar para home
</Link>
</header>
);
}

View File

@ -0,0 +1,26 @@
import { useEffect, useState } from 'react';
function formatTime(totalSeconds) {
const hours = String(Math.floor(totalSeconds / 3600)).padStart(2, '0');
const minutes = String(Math.floor((totalSeconds % 3600) / 60)).padStart(2, '0');
const seconds = String(totalSeconds % 60).padStart(2, '0');
return `${hours}:${minutes}:${seconds}`;
}
export function useCallTimer(initialSeconds = 0) {
const [seconds, setSeconds] = useState(initialSeconds);
useEffect(() => {
const intervalId = window.setInterval(() => {
setSeconds((current) => current + 1);
}, 1000);
return () => window.clearInterval(intervalId);
}, []);
return {
seconds,
formattedTime: formatTime(seconds),
};
}

View File

@ -0,0 +1,222 @@
import { CallControls } from '../components/CallControls';
import { CallHeader } from '../components/CallHeader';
import { useCallTimer } from '../hooks/useCallTimer';
import { activeCall, callControls } from '../services/callMocks';
import { useViewport } from '../../../shared/hooks/useViewport';
export function CallPage() {
const { isMobile } = useViewport();
const { formattedTime } = useCallTimer(312);
return (
<main
style={{
minHeight: '100vh',
padding: '1.5rem',
background:
'radial-gradient(circle at top, rgba(0, 164, 183, 0.22), transparent 22%), radial-gradient(circle at bottom, rgba(181, 31, 31, 0.2), transparent 26%), linear-gradient(180deg, #061521 0%, #0a2233 100%)',
}}
>
<section
style={{
width: 'min(1480px, calc(100vw - 3rem))',
minHeight: 'calc(100vh - 3rem)',
margin: '0 auto',
borderRadius: '36px',
padding: '1.5rem',
display: 'grid',
gap: '1.5rem',
background:
'linear-gradient(180deg, rgba(8, 22, 34, 0.94), rgba(10, 34, 51, 0.92))',
border: '1px solid rgba(255, 255, 255, 0.08)',
boxShadow: '0 30px 70px rgba(0, 0, 0, 0.35)',
color: '#fff',
}}
>
<CallHeader isMobile={isMobile} />
<section
style={{
display: 'grid',
gridTemplateColumns: isMobile ? '1fr' : 'minmax(280px, 360px) minmax(0, 1fr)',
gap: '1.5rem',
alignItems: 'stretch',
flex: 1,
}}
>
<aside
style={{
borderRadius: '30px',
padding: '1.5rem',
background: 'rgba(255, 255, 255, 0.05)',
border: '1px solid rgba(255, 255, 255, 0.08)',
display: 'grid',
gap: '1rem',
alignContent: 'start',
}}
>
<span
style={{
width: 'fit-content',
padding: '0.45rem 0.8rem',
borderRadius: '999px',
background: 'rgba(229, 162, 42, 0.16)',
color: '#ffd37f',
fontWeight: 700,
fontSize: '0.84rem',
}}
>
{activeCall.queue}
</span>
<div
aria-hidden="true"
style={{
width: isMobile ? 110 : 132,
height: isMobile ? 110 : 132,
borderRadius: '32px',
background: 'linear-gradient(135deg, #0d3d5d, #00a4b7)',
display: 'grid',
placeItems: 'center',
fontSize: isMobile ? '2rem' : '2.4rem',
fontWeight: 800,
boxShadow: '0 20px 40px rgba(0, 0, 0, 0.28)',
}}
>
{activeCall.initials}
</div>
<div>
<strong style={{ display: 'block', fontSize: '1.5rem' }}>{activeCall.customerName}</strong>
<span style={{ color: 'rgba(255, 255, 255, 0.7)' }}>{activeCall.role}</span>
</div>
<div
style={{
display: 'grid',
gap: '0.85rem',
}}
>
{[
{ label: 'Numero', value: activeCall.number },
{ label: 'Canal original', value: 'Atendimento omnichannel' },
{ label: 'Responsavel atual', value: 'Ana Camolesi' },
].map((item) => (
<article
key={item.label}
style={{
borderRadius: '22px',
padding: '1rem',
background: 'rgba(255, 255, 255, 0.05)',
}}
>
<span style={{ color: 'rgba(255, 255, 255, 0.62)', display: 'block' }}>
{item.label}
</span>
<strong style={{ display: 'block', marginTop: '0.35rem' }}>{item.value}</strong>
</article>
))}
</div>
</aside>
<section
style={{
display: 'grid',
gap: '1.5rem',
alignContent: 'space-between',
}}
>
<div
style={{
borderRadius: '32px',
padding: isMobile ? '1.5rem' : '2.25rem',
background:
'radial-gradient(circle at top right, rgba(229, 162, 42, 0.2), transparent 28%), rgba(255, 255, 255, 0.04)',
border: '1px solid rgba(255, 255, 255, 0.08)',
display: 'grid',
gap: '1rem',
justifyItems: 'center',
textAlign: 'center',
minHeight: isMobile ? 'auto' : 360,
alignContent: 'center',
}}
>
<span
style={{
width: 'fit-content',
padding: '0.5rem 0.9rem',
borderRadius: '999px',
background: 'rgba(0, 164, 183, 0.16)',
color: '#8ce8f2',
fontWeight: 700,
}}
>
Chamada em andamento
</span>
<strong style={{ fontSize: isMobile ? '2.7rem' : '4rem', letterSpacing: '0.08em' }}>
{formattedTime}
</strong>
<p
style={{
margin: 0,
maxWidth: '34rem',
color: 'rgba(255, 255, 255, 0.74)',
lineHeight: 1.6,
}}
>
Voce esta em uma ligacao ativa com a cliente. Os controles abaixo sao visuais
neste MVP e ajudam a demonstrar a experiencia de voz do produto.
</p>
</div>
<CallControls controls={callControls} isMobile={isMobile} />
<div
style={{
display: 'grid',
gridTemplateColumns: isMobile ? '1fr' : 'repeat(2, minmax(0, 1fr)) auto',
gap: '1rem',
alignItems: 'center',
}}
>
<div
style={{
borderRadius: '24px',
padding: '1rem 1.15rem',
background: 'rgba(255, 255, 255, 0.05)',
color: 'rgba(255, 255, 255, 0.72)',
}}
>
Qualidade da chamada: Estavel
</div>
<div
style={{
borderRadius: '24px',
padding: '1rem 1.15rem',
background: 'rgba(255, 255, 255, 0.05)',
color: 'rgba(255, 255, 255, 0.72)',
}}
>
Gravacao mock: Habilitada
</div>
<button
type="button"
style={{
border: 'none',
borderRadius: '24px',
padding: '1rem 1.4rem',
background: 'linear-gradient(135deg, var(--color-secondary), #d43a3a)',
color: '#fff',
fontWeight: 800,
minHeight: 58,
}}
>
Encerrar chamada
</button>
</div>
</section>
</section>
</section>
</main>
);
}

View File

@ -0,0 +1,15 @@
export const activeCall = {
id: 'call-1',
customerName: 'Maria Souza',
role: 'Cliente VIP • Ligação via PABX',
number: '+55 11 99888-7766',
queue: 'Fila Suporte Premium',
initials: 'MS',
};
export const callControls = [
{ id: 'mute', label: 'Mudo', hint: 'Microfone' },
{ id: 'keypad', label: 'Teclado', hint: 'DTMF' },
{ id: 'speaker', label: 'Alto-falante', hint: 'Saida de audio' },
{ id: 'transfer', label: 'Transferir', hint: 'Encaminhar' },
];

View File

@ -0,0 +1,108 @@
function ChannelBadge({ channel }) {
const colors = {
WhatsApp: '#2bb741',
Email: '#e5a22a',
SMS: '#00a4b7',
};
return (
<span
style={{
display: 'inline-flex',
alignItems: 'center',
borderRadius: 999,
padding: '0.22rem 0.6rem',
background: `${colors[channel] || '#003150'}16`,
color: colors[channel] || '#003150',
fontSize: '0.8rem',
fontWeight: 700,
}}
>
{channel}
</span>
);
}
export function ChatConversationList({
contacts,
activeContactId,
onSelectContact,
isMobile = false,
}) {
return (
<aside
style={{
background: '#fff',
border: '1px solid var(--color-border)',
borderRadius: '28px',
padding: '1rem',
display: 'grid',
gap: '0.85rem',
}}
>
<div>
<strong style={{ display: 'block', fontSize: '1.08rem' }}>Conversas ativas</strong>
<span style={{ color: 'var(--color-text-soft)' }}>
WhatsApp, SMS e email em uma fila visual.
</span>
</div>
<div
style={{
display: 'grid',
gap: '0.75rem',
gridTemplateColumns: isMobile ? '1fr' : '1fr',
}}
>
{contacts.map((contact) => {
const isActive = contact.id === activeContactId;
return (
<button
key={contact.id}
type="button"
onClick={() => onSelectContact(contact.id)}
style={{
border: '1px solid',
borderColor: isActive ? 'rgba(0, 164, 183, 0.26)' : 'var(--color-border)',
background: isActive ? 'rgba(0, 164, 183, 0.08)' : '#fff',
borderRadius: '20px',
padding: '1rem',
textAlign: 'left',
display: 'grid',
gap: '0.6rem',
}}
>
<div style={{ display: 'flex', justifyContent: 'space-between', gap: '1rem' }}>
<strong>{contact.name}</strong>
<span style={{ fontSize: '0.82rem', color: 'var(--color-text-soft)' }}>
{contact.time}
</span>
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', gap: '0.75rem' }}>
<ChannelBadge channel={contact.channel} />
{contact.unread ? (
<span
style={{
minWidth: 24,
borderRadius: 999,
padding: '0.15rem 0.45rem',
background: 'var(--color-secondary)',
color: '#fff',
fontSize: '0.78rem',
fontWeight: 700,
textAlign: 'center',
}}
>
{contact.unread}
</span>
) : null}
</div>
<span style={{ color: 'var(--color-text-soft)' }}>{contact.preview}</span>
</button>
);
})}
</div>
</aside>
);
}

View File

@ -0,0 +1,112 @@
export function ChatTransferPanel({
isOpen,
transferArea,
setTransferArea,
transferAreas,
attendants,
transferAttendant,
setTransferAttendant,
transferNote,
setTransferNote,
onSubmit,
onClose,
}) {
if (!isOpen) {
return null;
}
const fieldStyle = {
width: '100%',
border: '1px solid var(--color-border)',
borderRadius: '16px',
padding: '0.9rem 1rem',
background: '#fff',
outline: 'none',
};
return (
<aside
style={{
background: '#fff',
border: '1px solid var(--color-border)',
borderRadius: '28px',
padding: '1.25rem',
display: 'grid',
gap: '1rem',
}}
>
<div style={{ display: 'flex', justifyContent: 'space-between', gap: '1rem' }}>
<div>
<strong style={{ display: 'block', fontSize: '1.06rem' }}>Transferir atendimento</strong>
<span style={{ color: 'var(--color-text-soft)' }}>
Reencaminhe a conversa para a area ideal.
</span>
</div>
<button
type="button"
onClick={onClose}
style={{
border: 'none',
background: 'transparent',
color: 'var(--color-text-soft)',
fontWeight: 700,
}}
>
Fechar
</button>
</div>
<label style={{ display: 'grid', gap: '0.45rem' }}>
<span style={{ fontWeight: 600 }}>Area</span>
<select value={transferArea} onChange={(event) => setTransferArea(event.target.value)} style={fieldStyle}>
{transferAreas.map((area) => (
<option key={area} value={area}>
{area}
</option>
))}
</select>
</label>
<label style={{ display: 'grid', gap: '0.45rem' }}>
<span style={{ fontWeight: 600 }}>Atendente</span>
<select
value={transferAttendant}
onChange={(event) => setTransferAttendant(event.target.value)}
style={fieldStyle}
>
{attendants.map((attendant) => (
<option key={attendant} value={attendant}>
{attendant}
</option>
))}
</select>
</label>
<label style={{ display: 'grid', gap: '0.45rem' }}>
<span style={{ fontWeight: 600 }}>Observacao</span>
<textarea
rows={5}
value={transferNote}
onChange={(event) => setTransferNote(event.target.value)}
placeholder="Contexto opcional para ajudar o proximo atendente."
style={{ ...fieldStyle, resize: 'vertical' }}
/>
</label>
<button
type="button"
onClick={onSubmit}
style={{
border: 'none',
borderRadius: '16px',
padding: '0.95rem 1rem',
background: 'linear-gradient(135deg, var(--color-primary), #0b5a86)',
color: '#fff',
fontWeight: 700,
}}
>
Confirmar transferencia
</button>
</aside>
);
}

View File

@ -0,0 +1,211 @@
import { useEffect, useRef } from 'react';
export function ChatWindow({
contact,
messages,
selectedArea,
setSelectedArea,
draft,
setDraft,
onSend,
onToggleTransfer,
isReplying,
isMobile = false,
}) {
const messagesRef = useRef(null);
useEffect(() => {
const container = messagesRef.current;
if (!container) {
return;
}
container.scrollTo({
top: container.scrollHeight,
behavior: 'smooth',
});
}, [messages, isReplying]);
return (
<section
style={{
background: '#fff',
border: '1px solid var(--color-border)',
borderRadius: '28px',
overflow: 'hidden',
display: 'grid',
gridTemplateRows: 'auto 1fr auto',
minHeight: 680,
}}
>
<header
style={{
padding: '1.25rem 1.5rem',
borderBottom: '1px solid var(--color-border)',
display: 'grid',
gridTemplateColumns: isMobile ? '1fr' : 'minmax(0, 1fr) auto',
gap: '1rem',
alignItems: 'center',
}}
>
<div>
<strong style={{ display: 'block', fontSize: '1.15rem' }}>{contact.name}</strong>
<span style={{ color: 'var(--color-text-soft)' }}>
{contact.status === 'online' ? 'Online' : 'Offline'} {contact.lastSeen}
</span>
</div>
<div
style={{
display: 'flex',
gap: '0.7rem',
flexWrap: 'wrap',
justifyContent: isMobile ? 'stretch' : 'flex-end',
}}
>
<select
value={selectedArea}
onChange={(event) => setSelectedArea(event.target.value)}
style={{
border: '1px solid var(--color-border)',
borderRadius: '14px',
padding: '0.8rem 0.95rem',
background: '#fff',
fontWeight: 600,
}}
>
<option>Suporte</option>
<option>Financeiro</option>
<option>Comercial</option>
</select>
<button
type="button"
onClick={onToggleTransfer}
style={{
border: 'none',
borderRadius: '14px',
padding: '0.8rem 1rem',
background: 'rgba(0, 49, 80, 0.08)',
color: 'var(--color-primary)',
fontWeight: 700,
}}
>
Transferir
</button>
</div>
</header>
<div
ref={messagesRef}
style={{
padding: '1.5rem',
display: 'grid',
gap: '0.9rem',
alignContent: 'start',
overflowY: 'auto',
background:
'radial-gradient(circle at top left, rgba(0, 164, 183, 0.06), transparent 22%), linear-gradient(180deg, rgba(245, 248, 251, 0.8), rgba(255, 255, 255, 0.95))',
}}
>
{messages.map((message) => {
const isAgent = message.sender === 'agent';
const isSystem = message.sender === 'system';
if (isSystem) {
return (
<div
key={message.id}
style={{
justifySelf: 'center',
padding: '0.7rem 1rem',
borderRadius: '999px',
background: 'rgba(0, 49, 80, 0.08)',
color: 'var(--color-primary)',
fontSize: '0.88rem',
fontWeight: 600,
}}
>
{message.text}
</div>
);
}
return (
<div
key={message.id}
style={{
justifySelf: isAgent ? 'end' : 'start',
maxWidth: isMobile ? '88%' : '72%',
padding: '0.95rem 1rem',
borderRadius: isAgent ? '18px 18px 6px 18px' : '18px 18px 18px 6px',
background: isAgent ? 'var(--color-primary)' : '#edf1f5',
color: isAgent ? '#fff' : 'var(--color-text)',
boxShadow: 'var(--shadow-md)',
}}
>
{message.text}
</div>
);
})}
{isReplying ? (
<div
style={{
justifySelf: 'start',
padding: '0.8rem 0.95rem',
borderRadius: '18px 18px 18px 6px',
background: '#edf1f5',
color: 'var(--color-text-soft)',
fontWeight: 600,
}}
>
Digitando...
</div>
) : null}
</div>
<footer
style={{
padding: '1rem 1.25rem 1.25rem',
borderTop: '1px solid var(--color-border)',
display: 'grid',
gridTemplateColumns: isMobile ? '1fr' : '1fr auto',
gap: '0.75rem',
}}
>
<input
type="text"
value={draft}
onChange={(event) => setDraft(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Enter') {
onSend();
}
}}
placeholder="Escreva sua mensagem..."
style={{
border: '1px solid var(--color-border)',
borderRadius: '18px',
padding: '0.95rem 1rem',
background: '#fff',
outline: 'none',
}}
/>
<button
type="button"
onClick={onSend}
style={{
border: 'none',
borderRadius: '18px',
padding: '0.95rem 1.2rem',
background: 'linear-gradient(135deg, var(--color-primary), #0b5a86)',
color: '#fff',
fontWeight: 700,
}}
>
Enviar
</button>
</footer>
</section>
);
}

View File

@ -0,0 +1,151 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import {
attendantsByArea,
chatContacts,
getMockReply,
transferAreas,
} from '../services/chatMocks';
function buildInitialMessages() {
return chatContacts.reduce((acc, contact) => {
acc[contact.id] = contact.messages;
return acc;
}, {});
}
export function useChat() {
const [contacts, setContacts] = useState(chatContacts);
const [activeContactId, setActiveContactId] = useState(chatContacts[0].id);
const [messagesByContact, setMessagesByContact] = useState(buildInitialMessages);
const [draft, setDraft] = useState('');
const [selectedArea, setSelectedArea] = useState(chatContacts[0].area);
const [isTransferOpen, setIsTransferOpen] = useState(false);
const [transferArea, setTransferArea] = useState('Suporte');
const [transferAttendant, setTransferAttendant] = useState(attendantsByArea.Suporte[0]);
const [transferNote, setTransferNote] = useState('');
const [isReplying, setIsReplying] = useState(false);
const replyTimeoutRef = useRef(null);
const activeContact = useMemo(
() => contacts.find((contact) => contact.id === activeContactId) || contacts[0],
[contacts, activeContactId],
);
const messages = messagesByContact[activeContactId] || [];
const attendants = attendantsByArea[transferArea] || [];
useEffect(() => {
setSelectedArea(activeContact.area);
}, [activeContact]);
useEffect(() => {
setTransferAttendant(attendants[0] || '');
}, [transferArea]);
useEffect(() => {
return () => {
if (replyTimeoutRef.current) {
window.clearTimeout(replyTimeoutRef.current);
}
};
}, []);
function updateContactPreview(contactId, preview) {
setContacts((current) =>
current.map((contact) =>
contact.id === contactId ? { ...contact, preview, time: 'Agora', unread: 0 } : contact,
),
);
}
function sendMessage() {
const trimmed = draft.trim();
if (!trimmed) {
return;
}
const newMessage = {
id: Date.now(),
sender: 'agent',
text: trimmed,
};
setMessagesByContact((current) => ({
...current,
[activeContactId]: [...(current[activeContactId] || []), newMessage],
}));
updateContactPreview(activeContactId, trimmed);
setDraft('');
setIsReplying(true);
replyTimeoutRef.current = window.setTimeout(() => {
const reply = {
id: Date.now() + 1,
sender: 'customer',
text: getMockReply(activeContact.name),
};
setMessagesByContact((current) => ({
...current,
[activeContactId]: [...(current[activeContactId] || []), reply],
}));
setContacts((current) =>
current.map((contact) =>
contact.id === activeContactId
? { ...contact, preview: reply.text, time: 'Agora', unread: contact.unread + 1 }
: contact,
),
);
setIsReplying(false);
}, 1400);
}
function submitTransfer() {
const note = transferNote.trim();
const transferMessage = note
? `Transferencia solicitada para ${transferArea} com ${transferAttendant}. Obs: ${note}`
: `Transferencia solicitada para ${transferArea} com ${transferAttendant}.`;
setMessagesByContact((current) => ({
...current,
[activeContactId]: [
...(current[activeContactId] || []),
{ id: Date.now() + 2, sender: 'system', text: transferMessage },
],
}));
setContacts((current) =>
current.map((contact) =>
contact.id === activeContactId ? { ...contact, area: transferArea } : contact,
),
);
setSelectedArea(transferArea);
setIsTransferOpen(false);
setTransferNote('');
}
return {
contacts,
activeContact,
activeContactId,
setActiveContactId,
messages,
draft,
setDraft,
sendMessage,
isReplying,
selectedArea,
setSelectedArea,
isTransferOpen,
setIsTransferOpen,
transferArea,
setTransferArea,
transferAreas,
attendants,
transferAttendant,
setTransferAttendant,
transferNote,
setTransferNote,
submitTransfer,
};
}

View File

@ -0,0 +1,189 @@
import { Link } from 'react-router-dom';
import { BrandMark } from '../../../shared/components/BrandMark';
import { useViewport } from '../../../shared/hooks/useViewport';
import { ChatConversationList } from '../components/ChatConversationList';
import { ChatTransferPanel } from '../components/ChatTransferPanel';
import { ChatWindow } from '../components/ChatWindow';
import { useChat } from '../hooks/useChat';
import { quickReplies } from '../services/chatMocks';
export function ChatPage() {
const { isWideDesktop, isDesktop, isTablet, isMobile } = useViewport();
const {
contacts,
activeContact,
activeContactId,
setActiveContactId,
messages,
draft,
setDraft,
sendMessage,
isReplying,
selectedArea,
setSelectedArea,
isTransferOpen,
setIsTransferOpen,
transferArea,
setTransferArea,
transferAreas,
attendants,
transferAttendant,
setTransferAttendant,
transferNote,
setTransferNote,
submitTransfer,
} = useChat();
const gridTemplateColumns = isMobile
? '1fr'
: isWideDesktop
? 'minmax(260px, 320px) minmax(0, 1.6fr) minmax(280px, 320px)'
: isDesktop || isTablet
? 'minmax(260px, 320px) minmax(0, 1fr)'
: '1fr';
return (
<main style={{ minHeight: '100vh', padding: '1.5rem' }}>
<section
style={{
width: 'min(1680px, calc(100vw - 3rem))',
margin: '0 auto',
background: 'var(--color-surface-strong)',
borderRadius: '32px',
boxShadow: 'var(--shadow-lg)',
padding: '1.5rem',
display: 'grid',
gap: '1.25rem',
}}
>
<header
style={{
display: 'grid',
gridTemplateColumns: isMobile ? '1fr' : 'auto 1fr auto',
gap: '1rem',
alignItems: 'center',
}}
>
<BrandMark />
<div
style={{
justifySelf: isMobile ? 'stretch' : 'center',
padding: '0.85rem 1rem',
borderRadius: '18px',
background: 'rgba(0, 49, 80, 0.06)',
color: 'var(--color-primary)',
fontWeight: 700,
textAlign: 'center',
}}
>
Atendimento em tempo real
</div>
<Link
to="/home"
style={{
justifySelf: isMobile ? 'stretch' : 'end',
borderRadius: '16px',
padding: '0.85rem 1rem',
background: 'var(--color-primary)',
color: '#fff',
fontWeight: 700,
textAlign: 'center',
}}
>
Voltar para home
</Link>
</header>
<section
style={{
display: 'grid',
gridTemplateColumns,
gap: '1rem',
alignItems: 'start',
}}
>
<ChatConversationList
contacts={contacts}
activeContactId={activeContactId}
onSelectContact={setActiveContactId}
isMobile={isMobile}
/>
<div style={{ display: 'grid', gap: '1rem', minWidth: 0 }}>
<ChatWindow
contact={activeContact}
messages={messages}
selectedArea={selectedArea}
setSelectedArea={setSelectedArea}
draft={draft}
setDraft={setDraft}
onSend={sendMessage}
onToggleTransfer={() => setIsTransferOpen((current) => !current)}
isReplying={isReplying}
isMobile={isMobile}
/>
<div
style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(220px, 1fr))',
gap: '0.75rem',
}}
>
{quickReplies.map((reply) => (
<button
key={reply}
type="button"
onClick={() => setDraft(reply)}
style={{
border: '1px solid var(--color-border)',
borderRadius: '18px',
padding: '0.85rem 1rem',
background: '#fff',
color: 'var(--color-primary)',
fontWeight: 600,
textAlign: 'left',
}}
>
{reply}
</button>
))}
</div>
</div>
{isWideDesktop ? (
<ChatTransferPanel
isOpen={isTransferOpen}
transferArea={transferArea}
setTransferArea={setTransferArea}
transferAreas={transferAreas}
attendants={attendants}
transferAttendant={transferAttendant}
setTransferAttendant={setTransferAttendant}
transferNote={transferNote}
setTransferNote={setTransferNote}
onSubmit={submitTransfer}
onClose={() => setIsTransferOpen(false)}
/>
) : null}
</section>
{!isWideDesktop ? (
<ChatTransferPanel
isOpen={isTransferOpen}
transferArea={transferArea}
setTransferArea={setTransferArea}
transferAreas={transferAreas}
attendants={attendants}
transferAttendant={transferAttendant}
setTransferAttendant={setTransferAttendant}
transferNote={transferNote}
setTransferNote={setTransferNote}
onSubmit={submitTransfer}
onClose={() => setIsTransferOpen(false)}
/>
) : null}
</section>
</main>
);
}

View File

@ -0,0 +1,74 @@
export const chatContacts = [
{
id: 'maria-souza',
name: 'Maria Souza',
channel: 'WhatsApp',
status: 'online',
area: 'Suporte',
lastSeen: 'Online agora',
preview: 'Preciso atualizar o cadastro do meu pedido.',
time: '09:42',
unread: 2,
messages: [
{ id: 1, sender: 'customer', text: 'Oi, bom dia! Preciso de ajuda com meu pedido.' },
{ id: 2, sender: 'agent', text: 'Bom dia, Maria! Claro, me conta o que aconteceu.' },
{ id: 3, sender: 'customer', text: 'Quero confirmar se o endereco foi alterado.' },
{ id: 4, sender: 'agent', text: 'Estou verificando aqui e te atualizo em instantes.' },
],
},
{
id: 'joao-pedro',
name: 'Joao Pedro',
channel: 'SMS',
status: 'offline',
area: 'Financeiro',
lastSeen: 'Visto ha 12 min',
preview: 'Pode me ligar em 10 minutos?',
time: '08:15',
unread: 1,
messages: [
{ id: 1, sender: 'customer', text: 'Recebi a cobranca em duplicidade.' },
{ id: 2, sender: 'agent', text: 'Vou analisar isso agora para voce.' },
{ id: 3, sender: 'customer', text: 'Pode me ligar em 10 minutos?' },
],
},
{
id: 'empresa-alpha',
name: 'Empresa Alpha',
channel: 'Email',
status: 'offline',
area: 'Comercial',
lastSeen: 'Visto ontem',
preview: 'Aguardando retorno sobre a proposta comercial.',
time: 'Ontem',
unread: 0,
messages: [
{ id: 1, sender: 'customer', text: 'Precisamos rever os valores da ultima proposta.' },
{ id: 2, sender: 'agent', text: 'Perfeito, vou encaminhar para o time comercial.' },
],
},
];
export const transferAreas = ['Suporte', 'Financeiro', 'Comercial'];
export const attendantsByArea = {
Suporte: ['Ana Camolesi', 'Rafael Lopes', 'Romero Britto'],
Financeiro: ['Roberto Pêra', 'Monica Limoeira', 'Edson Arantes'],
Comercial: ['Natasha Homanoff', 'Helena Pêra', 'Pedro Parque'],
};
export const quickReplies = [
'Recebi sua mensagem e ja vou verificar.',
'Consegue me confirmar o numero do protocolo?',
'Posso seguir com essa atualizacao por aqui.',
];
export function getMockReply(contactName) {
const replies = [
`Perfeito, obrigado pelo retorno, ${contactName.split(' ')[0]}.`,
'Tudo bem, fico no aguardo dessa confirmacao.',
'Entendi. Se precisar, posso encaminhar para a area responsavel.',
];
return replies[Math.floor(Math.random() * replies.length)];
}

View File

@ -0,0 +1,95 @@
import { useNavigate } from 'react-router-dom';
export function CallsWorkspace({ calls, isWideDesktop = false, isDesktop = false, isMobile = false }) {
const navigate = useNavigate();
return (
<section
style={{
background: '#fff',
borderRadius: '26px',
border: '1px solid var(--color-border)',
padding: '1.5rem',
display: 'grid',
gap: '1rem',
}}
>
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
gap: '1rem',
flexWrap: 'wrap',
}}
>
<div>
<strong style={{ display: 'block', fontSize: '1.1rem' }}>Ligacoes recentes</strong>
<span style={{ color: 'var(--color-text-soft)' }}>
Visualizacao rapida do fluxo de voz do time.
</span>
</div>
<button
type="button"
onClick={() => navigate('/call')}
style={{
border: 'none',
borderRadius: '18px',
padding: '0.95rem 1.1rem',
background: 'linear-gradient(135deg, var(--color-primary), #0b5a86)',
color: '#fff',
fontWeight: 700,
}}
>
Nova ligacao
</button>
</div>
<div style={{ display: 'grid', gap: '0.85rem' }}>
{calls.map((call) => (
<article
key={call.id}
style={{
borderRadius: '22px',
border: '1px solid var(--color-border)',
padding: '1rem 1.1rem',
display: 'grid',
gridTemplateColumns: isMobile
? '1fr'
: isWideDesktop
? 'minmax(0, 1.4fr) repeat(3, minmax(100px, 1fr)) auto'
: isDesktop
? 'minmax(0, 1.2fr) repeat(2, minmax(100px, 1fr)) auto'
: 'repeat(2, minmax(0, 1fr))',
gap: '1rem',
alignItems: 'center',
}}
>
<div>
<strong style={{ display: 'block' }}>{call.name}</strong>
<span style={{ color: 'var(--color-text-soft)' }}>{call.area}</span>
</div>
<span>{call.duration}</span>
<span>{call.status}</span>
{!isMobile ? <span style={{ color: 'var(--color-text-soft)' }}>Hoje</span> : null}
<button
type="button"
onClick={() => navigate('/call')}
style={{
border: '1px solid var(--color-border)',
borderRadius: '14px',
padding: '0.7rem 0.95rem',
background: '#fff',
color: 'var(--color-primary)',
fontWeight: 700,
}}
>
Ver
</button>
</article>
))}
</div>
</section>
);
}

View File

@ -0,0 +1,82 @@
import { useNavigate } from 'react-router-dom';
export function HomeSidebar({ items, activeItem, isMobile = false }) {
const navigate = useNavigate();
return (
<aside
style={{
background: 'linear-gradient(180deg, rgba(0, 49, 80, 0.98), rgba(7, 64, 98, 0.96))',
color: '#fff',
borderRadius: '28px',
padding: '1.5rem',
display: 'grid',
gap: '1.25rem',
alignContent: 'start',
}}
>
<button
type="button"
onClick={() => navigate('/new-attendance')}
style={{
border: 'none',
borderRadius: '20px',
padding: '1rem 1.15rem',
background: 'linear-gradient(135deg, var(--color-highlight), #f3b94d)',
color: '#132534',
fontWeight: 800,
textAlign: 'left',
}}
>
+ Novo Atendimento
</button>
<nav
style={{
display: 'grid',
gap: '0.5rem',
gridTemplateColumns: isMobile ? 'repeat(auto-fit, minmax(180px, 1fr))' : '1fr',
}}
>
{items.map((item) => {
const isActive = item.id === activeItem;
return (
<button
key={item.id}
type="button"
onClick={() => item.route && navigate(item.route)}
style={{
border: 'none',
borderRadius: '18px',
padding: '0.9rem 1rem',
background: isActive ? 'rgba(255, 255, 255, 0.14)' : 'transparent',
color: '#fff',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
fontWeight: isActive ? 700 : 500,
width: '100%',
}}
>
<span>{item.label}</span>
{item.count ? (
<span
style={{
minWidth: 30,
borderRadius: 999,
padding: '0.2rem 0.5rem',
background: 'rgba(255, 255, 255, 0.12)',
fontSize: '0.82rem',
}}
>
{item.count}
</span>
) : null}
</button>
);
})}
</nav>
</aside>
);
}

View File

@ -0,0 +1,134 @@
export function HomeTopbar({
activeTab,
onTabChange,
searchValue,
onSearchChange,
isWideDesktop = false,
isDesktop = false,
isTablet = false,
isMobile = false,
}) {
const tabs = [
{ id: 'messages', label: 'Mensagens' },
{ id: 'calls', label: 'Ligacoes' },
];
const gridTemplateColumns = isMobile
? '1fr'
: isWideDesktop
? 'max-content minmax(180px, 220px) minmax(280px, 1fr) max-content'
: isDesktop || isTablet
? 'repeat(2, minmax(0, 1fr))'
: '1fr';
return (
<header
style={{
display: 'grid',
gridTemplateColumns,
gap: '1rem',
alignItems: 'center',
}}
>
<div
style={{
display: 'inline-flex',
padding: '0.35rem',
borderRadius: '18px',
background: 'rgba(0, 49, 80, 0.06)',
gap: '0.35rem',
width: isMobile ? '100%' : 'fit-content',
justifyContent: isMobile ? 'space-between' : 'flex-start',
minWidth: 0,
}}
>
{tabs.map((tab) => {
const isActive = tab.id === activeTab;
return (
<button
key={tab.id}
type="button"
onClick={() => onTabChange(tab.id)}
style={{
border: 'none',
borderRadius: '14px',
padding: '0.8rem 1rem',
background: isActive ? 'var(--color-primary)' : 'transparent',
color: isActive ? '#fff' : 'var(--color-primary)',
fontWeight: 700,
}}
>
{tab.label}
</button>
);
})}
</div>
<div
style={{
padding: '0.9rem 1.1rem',
borderRadius: '18px',
background: '#fff',
border: '1px solid var(--color-border)',
color: 'var(--color-text-soft)',
fontWeight: 600,
width: isMobile ? '100%' : 'auto',
minWidth: 0,
}}
>
Sexta, 19 de marco
</div>
<input
type="search"
placeholder="Buscar conversas, contatos ou protocolos"
value={searchValue}
onChange={(event) => onSearchChange(event.target.value)}
style={{
width: '100%',
border: '1px solid var(--color-border)',
borderRadius: '18px',
padding: '0.95rem 1rem',
background: '#fff',
outline: 'none',
minWidth: 0,
}}
/>
<div
style={{
display: 'flex',
alignItems: 'center',
gap: '0.9rem',
justifySelf: isMobile ? 'stretch' : 'end',
justifyContent: isMobile ? 'space-between' : 'flex-end',
width: isMobile ? '100%' : 'auto',
minWidth: 0,
}}
>
<div style={{ textAlign: 'right', minWidth: 0 }}>
<strong style={{ display: 'block' }}>Ana Camolesi</strong>
<span style={{ color: 'var(--color-text-soft)', fontSize: '0.92rem' }}>
Atendimento omnichannel
</span>
</div>
<div
aria-hidden="true"
style={{
width: 48,
height: 48,
borderRadius: '16px',
display: 'grid',
placeItems: 'center',
background: 'linear-gradient(135deg, var(--color-accent), var(--color-primary))',
color: '#fff',
fontWeight: 800,
}}
>
AM
</div>
</div>
</header>
);
}

View File

@ -0,0 +1,308 @@
import { useNavigate } from 'react-router-dom';
function ChannelBadge({ channel }) {
const colors = {
WhatsApp: '#2bb741',
Email: '#e5a22a',
SMS: '#00a4b7',
};
return (
<span
style={{
display: 'inline-flex',
alignItems: 'center',
borderRadius: 999,
padding: '0.22rem 0.6rem',
background: `${colors[channel] || '#003150'}16`,
color: colors[channel] || '#003150',
fontSize: '0.8rem',
fontWeight: 700,
}}
>
{channel}
</span>
);
}
export function MessagesWorkspace({
conversations,
activeConversationId,
onSelectConversation,
actionItems,
isWideDesktop = false,
isDesktop = false,
isTablet = false,
isMobile = false,
}) {
const navigate = useNavigate();
const activeConversation =
conversations.find((conversation) => conversation.id === activeConversationId) ||
conversations[0];
const gridTemplateColumns = isMobile
? '1fr'
: isWideDesktop
? 'minmax(240px, 0.95fr) minmax(360px, 1.8fr) minmax(220px, 0.8fr)'
: isDesktop || isTablet
? 'minmax(260px, 320px) minmax(0, 1fr)'
: '1fr';
return (
<div
style={{
display: 'grid',
gridTemplateColumns,
gap: '1rem',
alignItems: 'start',
}}
>
<section
style={{
background: '#fff',
borderRadius: '26px',
border: '1px solid var(--color-border)',
padding: '1rem',
display: 'grid',
gap: '0.75rem',
minWidth: 0,
}}
>
<div>
<strong style={{ fontSize: '1.05rem' }}>Conversas</strong>
<p style={{ margin: '0.35rem 0 0', color: 'var(--color-text-soft)' }}>
Atendimento em tempo real por canal.
</p>
</div>
{conversations.map((conversation) => {
const isActive = conversation.id === activeConversation.id;
return (
<button
key={conversation.id}
type="button"
onClick={() => onSelectConversation(conversation.id)}
style={{
border: '1px solid',
borderColor: isActive ? 'rgba(0, 164, 183, 0.26)' : 'var(--color-border)',
borderRadius: '20px',
padding: '1rem',
background: isActive ? 'rgba(0, 164, 183, 0.08)' : '#fff',
textAlign: 'left',
display: 'grid',
gap: '0.6rem',
}}
>
<div style={{ display: 'flex', justifyContent: 'space-between', gap: '1rem' }}>
<strong>{conversation.name}</strong>
<span style={{ color: 'var(--color-text-soft)', fontSize: '0.86rem' }}>
{conversation.time}
</span>
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', gap: '0.75rem' }}>
<ChannelBadge channel={conversation.channel} />
{conversation.unread ? (
<span
style={{
minWidth: 24,
borderRadius: 999,
padding: '0.15rem 0.45rem',
background: 'var(--color-secondary)',
color: '#fff',
fontSize: '0.78rem',
fontWeight: 700,
textAlign: 'center',
}}
>
{conversation.unread}
</span>
) : null}
</div>
<span style={{ color: 'var(--color-text-soft)' }}>{conversation.lastMessage}</span>
</button>
);
})}
</section>
<section
style={{
background: '#fff',
borderRadius: '26px',
border: '1px solid var(--color-border)',
display: 'grid',
gridTemplateRows: 'auto 1fr auto',
minHeight: 580,
overflow: 'hidden',
minWidth: 0,
}}
>
<header
style={{
padding: '1.15rem 1.25rem',
borderBottom: '1px solid var(--color-border)',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: '1rem',
}}
>
<div>
<strong style={{ display: 'block', fontSize: '1.08rem' }}>{activeConversation.name}</strong>
<span style={{ color: 'var(--color-text-soft)' }}>
{activeConversation.status === 'online' ? 'Online agora' : 'Offline'}
</span>
</div>
<div style={{ display: 'flex', gap: '0.6rem', flexWrap: 'wrap' }}>
<button
type="button"
onClick={() => navigate('/chat')}
style={{
border: '1px solid var(--color-border)',
borderRadius: '14px',
padding: '0.7rem 0.9rem',
background: '#fff',
color: 'var(--color-primary)',
fontWeight: 700,
}}
>
Abrir chat
</button>
<button
type="button"
style={{
border: 'none',
borderRadius: '14px',
padding: '0.7rem 0.9rem',
background: 'rgba(0, 49, 80, 0.08)',
color: 'var(--color-primary)',
fontWeight: 700,
}}
>
Transferir
</button>
</div>
</header>
<div
style={{
padding: '1.25rem',
display: 'grid',
gap: '0.9rem',
alignContent: 'start',
background:
'linear-gradient(180deg, rgba(245, 248, 251, 0.45), rgba(255, 255, 255, 0.9))',
}}
>
{activeConversation.messages.map((message) => {
const isAgent = message.from === 'agent';
return (
<div
key={message.id}
style={{
justifySelf: isAgent ? 'end' : 'start',
maxWidth: '72%',
padding: '0.95rem 1rem',
borderRadius: isAgent ? '18px 18px 6px 18px' : '18px 18px 18px 6px',
background: isAgent ? 'var(--color-primary)' : '#edf1f5',
color: isAgent ? '#fff' : 'var(--color-text)',
boxShadow: 'var(--shadow-md)',
}}
>
{message.text}
</div>
);
})}
</div>
<footer
style={{
padding: '1rem 1.25rem 1.25rem',
borderTop: '1px solid var(--color-border)',
display: 'grid',
gridTemplateColumns: '1fr auto',
gap: '0.75rem',
}}
>
<input
type="text"
value="Posso acionar o time responsavel e te retorno em seguida."
readOnly
style={{
border: '1px solid var(--color-border)',
borderRadius: '18px',
padding: '0.95rem 1rem',
background: '#fff',
}}
/>
<button
type="button"
style={{
border: 'none',
borderRadius: '18px',
padding: '0.95rem 1.2rem',
background: 'linear-gradient(135deg, var(--color-primary), #0b5a86)',
color: '#fff',
fontWeight: 700,
}}
>
Enviar
</button>
</footer>
</section>
<aside
style={{
background: '#fff',
borderRadius: '26px',
border: '1px solid var(--color-border)',
padding: '1.2rem',
display: 'grid',
gap: '1rem',
alignContent: 'start',
gridColumn: isWideDesktop ? 'auto' : '1 / -1',
minWidth: 0,
}}
>
<div>
<strong style={{ fontSize: '1.05rem' }}>Painel de acoes</strong>
<p style={{ margin: '0.35rem 0 0', color: 'var(--color-text-soft)' }}>
Contexto rapido do atendimento selecionado.
</p>
</div>
{actionItems.map((item) => (
<article
key={item.title}
style={{
borderRadius: '20px',
padding: '1rem',
background: 'rgba(0, 49, 80, 0.04)',
}}
>
<span style={{ color: 'var(--color-text-soft)', display: 'block', marginBottom: '0.35rem' }}>
{item.title}
</span>
<strong>{item.value}</strong>
</article>
))}
<button
type="button"
onClick={() => navigate('/new-attendance')}
style={{
border: '1px solid var(--color-border)',
borderRadius: '18px',
padding: '0.95rem 1rem',
background: '#fff',
color: 'var(--color-primary)',
fontWeight: 700,
}}
>
Criar novo fluxo
</button>
</aside>
</div>
);
}

View File

@ -0,0 +1,152 @@
import { useState } from 'react';
import { BrandMark } from '../../../shared/components/BrandMark';
import { HomeSidebar } from '../components/HomeSidebar';
import { HomeTopbar } from '../components/HomeTopbar';
import { MessagesWorkspace } from '../components/MessagesWorkspace';
import { CallsWorkspace } from '../components/CallsWorkspace';
import { actionItems, conversations, recentCalls, sidebarItems } from '../services/homeMocks';
import { useViewport } from '../../../shared/hooks/useViewport';
export function HomePage() {
const { isWideDesktop, isDesktop, isTablet, isMobile } = useViewport();
const [activeTab, setActiveTab] = useState('messages');
const [searchValue, setSearchValue] = useState('');
const [activeConversationId, setActiveConversationId] = useState(conversations[0].id);
const search = searchValue.trim().toLowerCase();
const filteredConversations = !search
? conversations
: conversations.filter((conversation) => {
const haystack = `${conversation.name} ${conversation.channel} ${conversation.lastMessage}`;
return haystack.toLowerCase().includes(search);
});
const safeConversationId =
filteredConversations.find((conversation) => conversation.id === activeConversationId)?.id ||
filteredConversations[0]?.id ||
conversations[0].id;
return (
<main
style={{
minHeight: '100vh',
padding: '1.5rem',
}}
>
<section
style={{
width: 'min(1680px, calc(100vw - 3rem))',
margin: '0 auto',
background: 'var(--color-surface-strong)',
borderRadius: '32px',
boxShadow: 'var(--shadow-lg)',
padding: '1.5rem',
display: 'grid',
gap: '1.5rem',
}}
>
<div
style={{
display: 'grid',
gridTemplateColumns: isDesktop ? 'minmax(340px, 380px) minmax(0, 1fr)' : '1fr',
gap: '1.5rem',
alignItems: 'start',
}}
>
<div
style={{
display: 'grid',
gap: '1.25rem',
}}
>
<div
style={{
background: '#fff',
border: '1px solid var(--color-border)',
borderRadius: '28px',
padding: '1.5rem',
}}
>
<BrandMark size="lg" />
</div>
<HomeSidebar items={sidebarItems} activeItem="dashboard" isMobile={!isDesktop} />
</div>
<div style={{ display: 'grid', gap: '1.25rem', minWidth: 0 }}>
<HomeTopbar
activeTab={activeTab}
onTabChange={setActiveTab}
searchValue={searchValue}
onSearchChange={setSearchValue}
isWideDesktop={isWideDesktop}
isDesktop={isDesktop}
isTablet={isTablet}
isMobile={isMobile}
/>
<section
style={{
display: 'grid',
gap: '1rem',
}}
>
<div
style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(180px, 1fr))',
gap: '1rem',
}}
>
{[
{ label: 'Atendimentos ativos', value: '18', detail: '7 aguardando retorno' },
{ label: 'Primeira resposta', value: '2m 14s', detail: 'Dentro do SLA' },
{ label: 'Fila de voz', value: '4 chamadas', detail: '1 prioridade alta' },
].map((item) => (
<article
key={item.label}
style={{
padding: '1.15rem',
borderRadius: '22px',
border: '1px solid var(--color-border)',
background: '#fff',
}}
>
<span style={{ color: 'var(--color-text-soft)', display: 'block' }}>
{item.label}
</span>
<strong style={{ display: 'block', fontSize: '1.4rem', marginTop: '0.45rem' }}>
{item.value}
</strong>
<span style={{ color: 'var(--color-text-soft)', display: 'block', marginTop: '0.45rem' }}>
{item.detail}
</span>
</article>
))}
</div>
{activeTab === 'messages' ? (
<MessagesWorkspace
conversations={filteredConversations}
activeConversationId={safeConversationId}
onSelectConversation={setActiveConversationId}
actionItems={actionItems}
isWideDesktop={isWideDesktop}
isDesktop={isDesktop}
isTablet={isTablet}
isMobile={isMobile}
/>
) : (
<CallsWorkspace
calls={recentCalls}
isWideDesktop={isWideDesktop}
isDesktop={isDesktop}
isMobile={isMobile}
/>
)}
</section>
</div>
</div>
</section>
</main>
);
}

View File

@ -0,0 +1,63 @@
export const sidebarItems = [
{ id: 'dashboard', label: 'Dashboard' },
{ id: 'new-attendance', label: 'Novos Atendimentos', route: '/new-attendance' },
{ id: 'in-progress', label: 'Em andamento', count: 8 },
{ id: 'completed', label: 'Finalizados', count: 24 },
{ id: 'contacts', label: 'Contatos', count: 128 },
];
export const conversations = [
{
id: 'maria-souza',
name: 'Maria Souza',
channel: 'WhatsApp',
status: 'online',
lastMessage: 'Preciso atualizar o cadastro do meu pedido.',
unread: 2,
time: '09:42',
messages: [
{ id: 1, from: 'customer', text: 'Oi, bom dia! Preciso de ajuda com meu pedido.' },
{ id: 2, from: 'agent', text: 'Bom dia, Maria! Claro, me conta o que aconteceu.' },
{ id: 3, from: 'customer', text: 'Quero confirmar se o endereco foi alterado.' },
],
},
{
id: 'empresa-alpha',
name: 'Empresa Alpha',
channel: 'Email',
status: 'offline',
lastMessage: 'Aguardando retorno sobre a proposta comercial.',
unread: 0,
time: 'Ontem',
messages: [
{ id: 1, from: 'customer', text: 'Precisamos rever os valores da ultima proposta.' },
{ id: 2, from: 'agent', text: 'Perfeito, vou encaminhar para o time comercial.' },
],
},
{
id: 'joao-pedro',
name: 'Joao Pedro',
channel: 'SMS',
status: 'online',
lastMessage: 'Pode me ligar em 10 minutos?',
unread: 1,
time: '08:15',
messages: [
{ id: 1, from: 'customer', text: 'Recebi a cobranca em duplicidade.' },
{ id: 2, from: 'agent', text: 'Vou analisar isso agora para voce.' },
{ id: 3, from: 'customer', text: 'Pode me ligar em 10 minutos?' },
],
},
];
export const actionItems = [
{ title: 'Area atual', value: 'Suporte' },
{ title: 'SLA restante', value: '18 min' },
{ title: 'Prioridade', value: 'Alta' },
];
export const recentCalls = [
{ id: 'call-1', name: 'Beatriz Lima', area: 'Comercial', duration: '12:48', status: 'Encerrada' },
{ id: 'call-2', name: 'Carlos Nunes', area: 'Suporte', duration: '03:12', status: 'Perdida' },
{ id: 'call-3', name: 'Grupo Solaris', area: 'Financeiro', duration: '08:55', status: 'Em andamento' },
];

33
src/routes/router.jsx Normal file
View File

@ -0,0 +1,33 @@
import { createBrowserRouter, Navigate } from 'react-router-dom';
import { LoginPage } from '../modules/auth/pages/LoginPage';
import { HomePage } from '../modules/home/pages/HomePage';
import { ChatPage } from '../modules/chat/pages/ChatPage';
import { CallPage } from '../modules/call/pages/CallPage';
import { NewAttendancePage } from '../modules/attendance/pages/NewAttendancePage';
export const router = createBrowserRouter([
{
path: '/',
element: <Navigate to="/login" replace />,
},
{
path: '/login',
element: <LoginPage />,
},
{
path: '/home',
element: <HomePage />,
},
{
path: '/chat',
element: <ChatPage />,
},
{
path: '/call',
element: <CallPage />,
},
{
path: '/new-attendance',
element: <NewAttendancePage />,
},
]);

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

View File

@ -0,0 +1,57 @@
import logoLight from '../assets/logo_white_mode.png';
import logoDark from '../assets/logo_white_dark_mode.png';
const sizes = {
sm: {
gap: '0.75rem',
logoHeight: 42,
titleSize: '1rem',
subtitleSize: '0.85rem',
},
md: {
gap: '1rem',
logoHeight: 54,
titleSize: '1.2rem',
subtitleSize: '0.95rem',
},
lg: {
gap: '1.25rem',
logoHeight: 78,
titleSize: '1.5rem',
subtitleSize: '1.05rem',
},
};
export function BrandMark({ compact = false, theme = 'light', size }) {
const logoSrc = theme === 'dark' ? logoDark : logoLight;
const variant = size || (compact ? 'sm' : 'md');
const currentSize = sizes[variant] || sizes.md;
return (
<div
style={{
display: 'flex',
alignItems: 'center',
gap: currentSize.gap,
}}
>
<img
src={logoSrc}
alt="Sothis"
style={{
height: currentSize.logoHeight,
width: 'auto',
objectFit: 'contain',
}}
/>
<div>
<div style={{ fontSize: currentSize.titleSize, fontWeight: 800 }}>
Sothis Omnichannel
</div>
<div style={{ color: 'var(--color-text-soft)', fontSize: currentSize.subtitleSize }}>
Central de atendimento unificada
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,30 @@
import { useEffect, useState } from 'react';
function getViewportWidth() {
if (typeof window === 'undefined') {
return 1440;
}
return window.innerWidth;
}
export function useViewport() {
const [width, setWidth] = useState(getViewportWidth);
useEffect(() => {
function handleResize() {
setWidth(window.innerWidth);
}
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, []);
return {
width,
isWideDesktop: width >= 1500,
isDesktop: width >= 1180,
isTablet: width < 1180 && width >= 760,
isMobile: width < 760,
};
}

View File

@ -0,0 +1,50 @@
:root {
font-family: "Segoe UI", "Helvetica Neue", sans-serif;
color: #122230;
background:
radial-gradient(circle at top left, rgba(0, 164, 183, 0.12), transparent 28%),
radial-gradient(circle at bottom right, rgba(229, 162, 42, 0.14), transparent 24%),
#f5f8fb;
color-scheme: light;
--color-primary: #003150;
--color-secondary: #b51f1f;
--color-accent: #00a4b7;
--color-highlight: #e5a22a;
--color-surface: rgba(255, 255, 255, 0.9);
--color-surface-strong: #ffffff;
--color-text: #122230;
--color-text-soft: #5e6d7b;
--color-border: rgba(0, 49, 80, 0.12);
--shadow-lg: 0 24px 60px rgba(0, 49, 80, 0.12);
--shadow-md: 0 12px 28px rgba(0, 49, 80, 0.08);
}
* {
box-sizing: border-box;
}
html,
body,
#root {
min-height: 100%;
margin: 0;
}
body {
min-height: 100vh;
}
body,
button,
input {
font: inherit;
}
button {
cursor: pointer;
}
a {
color: inherit;
text-decoration: none;
}

14
vite.config.js Normal file
View File

@ -0,0 +1,14 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
server: {
host: '0.0.0.0',
port: 3000,
},
preview: {
host: '0.0.0.0',
port: 3000,
},
});