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:
commit
8e29dde2a1
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
@ -0,0 +1 @@
|
||||
node_modules
|
||||
12
Dockerfile
Normal file
12
Dockerfile
Normal 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
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
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
1
dist/assets/index-BsY34Fgu.css
vendored
Normal 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}
|
||||
BIN
dist/assets/logo_white_dark_mode-BKcVSu03.png
vendored
Normal file
BIN
dist/assets/logo_white_dark_mode-BKcVSu03.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
BIN
dist/assets/logo_white_mode-BIHgqUPv.png
vendored
Normal file
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
14
dist/index.html
vendored
Normal 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
13
docs/README.md
Normal 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
105
docs/casos-de-uso-rpg.md
Normal 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
35
docs/modulo-attendance.md
Normal 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
33
docs/modulo-auth.md
Normal 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
32
docs/modulo-call.md
Normal 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
37
docs/modulo-chat.md
Normal 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
40
docs/modulo-home.md
Normal 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
83
docs/visao-geral.md
Normal 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
13
index.html
Normal 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
1719
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
20
package.json
Normal file
20
package.json
Normal 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
11
src/main.jsx
Normal 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>,
|
||||
);
|
||||
80
src/modules/attendance/components/RecentContactsList.jsx
Normal file
80
src/modules/attendance/components/RecentContactsList.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
340
src/modules/attendance/pages/NewAttendancePage.jsx
Normal file
340
src/modules/attendance/pages/NewAttendancePage.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
38
src/modules/attendance/services/attendanceMocks.js
Normal file
38
src/modules/attendance/services/attendanceMocks.js
Normal 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',
|
||||
},
|
||||
];
|
||||
97
src/modules/auth/components/LoginForm.jsx
Normal file
97
src/modules/auth/components/LoginForm.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
24
src/modules/auth/hooks/useLogin.js
Normal file
24
src/modules/auth/hooks/useLogin.js
Normal 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,
|
||||
};
|
||||
}
|
||||
161
src/modules/auth/pages/LoginPage.jsx
Normal file
161
src/modules/auth/pages/LoginPage.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
11
src/modules/auth/services/authService.js
Normal file
11
src/modules/auth/services/authService.js
Normal 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',
|
||||
};
|
||||
}
|
||||
114
src/modules/call/components/CallControls.jsx
Normal file
114
src/modules/call/components/CallControls.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
56
src/modules/call/components/CallHeader.jsx
Normal file
56
src/modules/call/components/CallHeader.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
26
src/modules/call/hooks/useCallTimer.js
Normal file
26
src/modules/call/hooks/useCallTimer.js
Normal 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),
|
||||
};
|
||||
}
|
||||
222
src/modules/call/pages/CallPage.jsx
Normal file
222
src/modules/call/pages/CallPage.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
15
src/modules/call/services/callMocks.js
Normal file
15
src/modules/call/services/callMocks.js
Normal 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' },
|
||||
];
|
||||
108
src/modules/chat/components/ChatConversationList.jsx
Normal file
108
src/modules/chat/components/ChatConversationList.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
112
src/modules/chat/components/ChatTransferPanel.jsx
Normal file
112
src/modules/chat/components/ChatTransferPanel.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
211
src/modules/chat/components/ChatWindow.jsx
Normal file
211
src/modules/chat/components/ChatWindow.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
151
src/modules/chat/hooks/useChat.js
Normal file
151
src/modules/chat/hooks/useChat.js
Normal 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,
|
||||
};
|
||||
}
|
||||
189
src/modules/chat/pages/ChatPage.jsx
Normal file
189
src/modules/chat/pages/ChatPage.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
74
src/modules/chat/services/chatMocks.js
Normal file
74
src/modules/chat/services/chatMocks.js
Normal 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)];
|
||||
}
|
||||
95
src/modules/home/components/CallsWorkspace.jsx
Normal file
95
src/modules/home/components/CallsWorkspace.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
82
src/modules/home/components/HomeSidebar.jsx
Normal file
82
src/modules/home/components/HomeSidebar.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
134
src/modules/home/components/HomeTopbar.jsx
Normal file
134
src/modules/home/components/HomeTopbar.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
308
src/modules/home/components/MessagesWorkspace.jsx
Normal file
308
src/modules/home/components/MessagesWorkspace.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
152
src/modules/home/pages/HomePage.jsx
Normal file
152
src/modules/home/pages/HomePage.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
63
src/modules/home/services/homeMocks.js
Normal file
63
src/modules/home/services/homeMocks.js
Normal 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
33
src/routes/router.jsx
Normal 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 />,
|
||||
},
|
||||
]);
|
||||
BIN
src/shared/assets/favicon_blue.png
Normal file
BIN
src/shared/assets/favicon_blue.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.8 KiB |
BIN
src/shared/assets/logo_white_dark_mode.png
Normal file
BIN
src/shared/assets/logo_white_dark_mode.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
BIN
src/shared/assets/logo_white_mode.png
Normal file
BIN
src/shared/assets/logo_white_mode.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 26 KiB |
57
src/shared/components/BrandMark.jsx
Normal file
57
src/shared/components/BrandMark.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
30
src/shared/hooks/useViewport.js
Normal file
30
src/shared/hooks/useViewport.js
Normal 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,
|
||||
};
|
||||
}
|
||||
50
src/shared/styles/global.css
Normal file
50
src/shared/styles/global.css
Normal 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
14
vite.config.js
Normal 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,
|
||||
},
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user