├── imgs ├── home.png ├── index.png ├── login.png ├── app.js.png ├── index2.png ├── swagger.png ├── reclamacao.png ├── homescreeng.png ├── logoDesatende.png └── reclamacaoParodia.png ├── app ├── imgs │ ├── Marvan4.png │ ├── Carrefive5.png │ ├── CocoBangu3.png │ ├── mcdonalds1.png │ └── americanScaralines2.png ├── src │ ├── api │ │ ├── empresas.js │ │ ├── axios.js │ │ ├── auth.js │ │ └── reclamacao.js │ └── hooks │ │ ├── useEmpresas.js │ │ ├── useMinhasReclamacoes.js │ │ ├── useReclamacoesRecebidas.js │ │ ├── useFeedback.js │ │ ├── useImagePicker.js │ │ ├── useRefresh.js │ │ └── useAuth.js ├── components │ ├── HeaderTitulo.jsx │ ├── AuthModal.jsx │ ├── CustomButton.jsx │ ├── Formulario.jsx │ ├── LogoutButton.jsx │ ├── EmpresaItem.jsx │ ├── Rodape.jsx │ ├── ModalRespostaReclamacao.jsx │ ├── ModalAvaliarReclamacao.jsx │ └── ModalCriarReclamacao.jsx ├── estilos │ ├── estilosLogin.js │ ├── estilosHome.js │ ├── estilosPerfil.js │ └── estilosPerfilEmpresa.js ├── perfil.jsx ├── dashboard.jsx ├── home.jsx └── index.jsx ├── backend ├── .env.example ├── src │ ├── middlewares │ │ ├── upload.js │ │ ├── rateLimiter.js │ │ ├── notFoundHandler.js │ │ ├── validate.js │ │ ├── errorHandler.js │ │ └── auth.js │ ├── config │ │ └── db.js │ ├── routes │ │ ├── user.js │ │ ├── empresa.js │ │ └── reclamacao.js │ ├── app.js │ ├── controllers │ │ ├── authUser.Controller.js │ │ ├── authEmpresa.Controller.js │ │ ├── user.Controller.js │ │ └── empresa.Controller.js │ ├── models │ │ ├── User.js │ │ ├── Empresa.js │ │ └── Reclamacao.js │ └── validators │ │ ├── authValidators.js │ │ └── reclamacaoValidators.js ├── server.js ├── swagger │ ├── swagger.js │ └── swagger.json ├── package.json └── tests │ ├── auth.test.js │ └── validators.test.js ├── app.js ├── .gitignore ├── app.json ├── package.json └── README.md /imgs/home.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mmyersbyte/appdesatende/HEAD/imgs/home.png -------------------------------------------------------------------------------- /imgs/index.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mmyersbyte/appdesatende/HEAD/imgs/index.png -------------------------------------------------------------------------------- /imgs/login.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mmyersbyte/appdesatende/HEAD/imgs/login.png -------------------------------------------------------------------------------- /imgs/app.js.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mmyersbyte/appdesatende/HEAD/imgs/app.js.png -------------------------------------------------------------------------------- /imgs/index2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mmyersbyte/appdesatende/HEAD/imgs/index2.png -------------------------------------------------------------------------------- /imgs/swagger.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mmyersbyte/appdesatende/HEAD/imgs/swagger.png -------------------------------------------------------------------------------- /imgs/reclamacao.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mmyersbyte/appdesatende/HEAD/imgs/reclamacao.png -------------------------------------------------------------------------------- /app/imgs/Marvan4.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mmyersbyte/appdesatende/HEAD/app/imgs/Marvan4.png -------------------------------------------------------------------------------- /imgs/homescreeng.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mmyersbyte/appdesatende/HEAD/imgs/homescreeng.png -------------------------------------------------------------------------------- /imgs/logoDesatende.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mmyersbyte/appdesatende/HEAD/imgs/logoDesatende.png -------------------------------------------------------------------------------- /app/imgs/Carrefive5.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mmyersbyte/appdesatende/HEAD/app/imgs/Carrefive5.png -------------------------------------------------------------------------------- /app/imgs/CocoBangu3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mmyersbyte/appdesatende/HEAD/app/imgs/CocoBangu3.png -------------------------------------------------------------------------------- /app/imgs/mcdonalds1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mmyersbyte/appdesatende/HEAD/app/imgs/mcdonalds1.png -------------------------------------------------------------------------------- /imgs/reclamacaoParodia.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mmyersbyte/appdesatende/HEAD/imgs/reclamacaoParodia.png -------------------------------------------------------------------------------- /app/imgs/americanScaralines2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mmyersbyte/appdesatende/HEAD/app/imgs/americanScaralines2.png -------------------------------------------------------------------------------- /backend/.env.example: -------------------------------------------------------------------------------- 1 | MONGODB_URI=coloque_sua_string_do_mongodb_aqui 2 | PORT=5000 3 | JWT_SECRET= 4 | NODE_ENV=development -------------------------------------------------------------------------------- /app.js: -------------------------------------------------------------------------------- 1 | import { ExpoRouter } from 'expo-router'; 2 | 3 | export default function App() { 4 | return ; 5 | } 6 | -------------------------------------------------------------------------------- /app/src/api/empresas.js: -------------------------------------------------------------------------------- 1 | import api from './axios'; 2 | export async function buscarEmpresas() { 3 | const response = await api.get('/empresas/listar-empresas'); 4 | return response.data.empresas; 5 | } 6 | -------------------------------------------------------------------------------- /backend/src/middlewares/upload.js: -------------------------------------------------------------------------------- 1 | import multer from 'multer'; 2 | 3 | const storage = multer.memoryStorage(); // Armazena arquivos em memória 4 | const upload = multer({ storage }); 5 | 6 | export default upload; 7 | -------------------------------------------------------------------------------- /backend/src/middlewares/rateLimiter.js: -------------------------------------------------------------------------------- 1 | import rateLimit from 'express-rate-limit'; 2 | 3 | export const limiter = rateLimit({ 4 | windowMs: 15 * 60 * 1000, // 15 minutos 5 | max: 100, // Limite de 100 requisições por IP 6 | }); 7 | -------------------------------------------------------------------------------- /backend/src/middlewares/notFoundHandler.js: -------------------------------------------------------------------------------- 1 | export default function notFoundHandler(req, res, next) { 2 | res.status(404).json({ 3 | error: 'Rota não encontrada', 4 | method: req.method, 5 | path: req.originalUrl, 6 | }); 7 | } 8 | -------------------------------------------------------------------------------- /backend/src/config/db.js: -------------------------------------------------------------------------------- 1 | import mongoose from 'mongoose'; 2 | 3 | export default async function connectDB(uri) { 4 | try { 5 | await mongoose.connect(uri); 6 | console.log('MongoDB conectado com sucesso!'); 7 | } catch (error) { 8 | console.error('Erro ao conectar no MongoDB:', error); 9 | process.exit(1); 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /backend/server.js: -------------------------------------------------------------------------------- 1 | import dotenv from 'dotenv'; 2 | import connectDB from './src/config/db.js'; 3 | import app from './src/app.js'; 4 | 5 | dotenv.config(); 6 | 7 | connectDB(process.env.MONGODB_URI); // meu tratamento de erro é direto no config/db.js 8 | const PORT = process.env.PORT || 5000; 9 | 10 | app.listen(PORT, () => { 11 | console.log(`Servidor rodando na porta ${PORT}`); 12 | }); 13 | -------------------------------------------------------------------------------- /app/src/api/axios.js: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | import Constants from 'expo-constants'; 3 | 4 | // Configuração do base URL usando variável de ambiente Expo 5 | const baseURL = Constants.expoConfig?.extra?.API_BASE_URL; 6 | 7 | const api = axios.create({ 8 | baseURL, 9 | timeout: 10000, 10 | headers: { 11 | 'Content-Type': 'application/json', 12 | }, 13 | }); 14 | 15 | export default api; 16 | -------------------------------------------------------------------------------- /backend/swagger/swagger.js: -------------------------------------------------------------------------------- 1 | import swaggerUi from 'swagger-ui-express'; 2 | import fs from 'fs'; 3 | import { fileURLToPath } from 'url'; 4 | import { dirname, join } from 'path'; 5 | 6 | // Gambi do ESModule pra poder usar __dirname 7 | const __filename = fileURLToPath(import.meta.url); 8 | const __dirname = dirname(__filename); 9 | 10 | // Lê o arquivo JSON manualmente 11 | const swaggerPath = join(__dirname, 'swagger.json'); 12 | const swaggerDocument = JSON.parse(fs.readFileSync(swaggerPath, 'utf8')); 13 | 14 | export default function setupSwagger(app) { 15 | app.use('/api-docs', swaggerUi.serve, swaggerUi.setup(swaggerDocument)); 16 | } 17 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Learn more https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files 2 | 3 | # dependencies 4 | node_modules/ 5 | /oie 6 | # Expo 7 | .expo/ 8 | dist/ 9 | web-build/ 10 | expo-env.d.ts 11 | 12 | # Native 13 | *.orig.* 14 | *.jks 15 | *.p8 16 | *.p12 17 | *.key 18 | *.mobileprovision 19 | 20 | # Metro 21 | .metro-health-check* 22 | 23 | # debug 24 | npm-debug.* 25 | yarn-debug.* 26 | yarn-error.* 27 | 28 | # macOS 29 | .DS_Store 30 | *.pem 31 | 32 | # local env files 33 | .env*.local 34 | .env 35 | 36 | # typescript 37 | *.tsbuildinfo 38 | 39 | app-example 40 | oie 41 | 42 | explicandoCodigo/ 43 | 44 | .env 45 | .env.local 46 | /env -------------------------------------------------------------------------------- /app/src/hooks/useEmpresas.js: -------------------------------------------------------------------------------- 1 | import { useState, useEffect } from 'react'; 2 | import { buscarEmpresas } from '../api/empresas.js'; 3 | 4 | export function useEmpresas() { 5 | const [empresas, setEmpresas] = useState([]); 6 | const [carregando, setCarregando] = useState(true); 7 | 8 | useEffect(() => { 9 | async function carregar() { 10 | try { 11 | const dados = await buscarEmpresas(); 12 | setEmpresas(dados); 13 | } catch (e) { 14 | console.error('Erro ao buscar empresas:', e); 15 | } finally { 16 | setCarregando(false); 17 | } 18 | } 19 | carregar(); 20 | }, []); 21 | 22 | return { empresas, carregando }; 23 | } 24 | -------------------------------------------------------------------------------- /backend/src/middlewares/validate.js: -------------------------------------------------------------------------------- 1 | // Função middleware para validar o corpo da requisição (req.body) 2 | // Recebe um schema Joi como parâmetro 3 | export function validateBody(schema) { 4 | // Retorna uma função middleware padrão do Express 5 | return (req, res, next) => { 6 | // Valida o corpo da requisição usando o schema Joi 7 | const { error } = schema.validate(req.body); 8 | // Se houver erro de validação 9 | if (error) { 10 | // Retorna status 400 (Bad Request) e a mensagem de erro 11 | return res.status(400).json({ message: error.details[0].message }); 12 | } 13 | // Se não houver erro, segue para o próximo middleware/controller 14 | next(); 15 | }; 16 | } 17 | -------------------------------------------------------------------------------- /backend/src/routes/user.js: -------------------------------------------------------------------------------- 1 | import express from 'express'; 2 | import { 3 | cadastrarUsuario, 4 | listarUsuarios, 5 | } from '../controllers/user.Controller.js'; 6 | import { loginUser } from '../controllers/authUser.Controller.js'; 7 | import { validateBody } from '../middlewares/validate.js'; 8 | import { 9 | loginSchema, 10 | cadastroUsuarioSchema, 11 | } from '../validators/authValidators.js'; 12 | 13 | const router = express.Router(); 14 | 15 | router.post('/login', validateBody(loginSchema), loginUser); 16 | router.post( 17 | '/cadastrar', 18 | validateBody(cadastroUsuarioSchema), 19 | cadastrarUsuario 20 | ); 21 | router.get('/listar-usuarios', listarUsuarios); 22 | 23 | export default router; 24 | -------------------------------------------------------------------------------- /app/src/hooks/useMinhasReclamacoes.js: -------------------------------------------------------------------------------- 1 | import { useState, useEffect } from 'react'; 2 | import { buscarMinhasReclamacoes } from '../api/reclamacao.js'; 3 | 4 | export function useMinhasReclamacoes() { 5 | const [reclamacoes, setReclamacoes] = useState([]); 6 | const [carregando, setCarregando] = useState(true); 7 | 8 | useEffect(() => { 9 | async function carregar() { 10 | try { 11 | const dados = await buscarMinhasReclamacoes(); 12 | setReclamacoes(dados); 13 | } catch (e) { 14 | console.error('Erro ao buscar reclamações:', e); 15 | } finally { 16 | setCarregando(false); 17 | } 18 | } 19 | carregar(); 20 | }, []); 21 | 22 | return { reclamacoes, carregando }; 23 | } 24 | -------------------------------------------------------------------------------- /backend/src/routes/empresa.js: -------------------------------------------------------------------------------- 1 | import express from 'express'; 2 | import { 3 | cadastrarEmpresa, 4 | listarEmpresas, 5 | buscarEmpresaPorId, 6 | } from '../controllers/empresa.Controller.js'; 7 | import { loginEmpresa } from '../controllers/authEmpresa.Controller.js'; 8 | import { validateBody } from '../middlewares/validate.js'; 9 | import { 10 | loginSchema, 11 | cadastroEmpresaSchema, 12 | } from '../validators/authValidators.js'; 13 | 14 | const router = express.Router(); 15 | 16 | router.get('/listar-empresas', listarEmpresas); 17 | router.get('/:id', buscarEmpresaPorId); 18 | router.post( 19 | '/cadastrar', 20 | validateBody(cadastroEmpresaSchema), 21 | cadastrarEmpresa 22 | ); 23 | router.post('/login', validateBody(loginSchema), loginEmpresa); 24 | 25 | export default router; 26 | -------------------------------------------------------------------------------- /backend/src/app.js: -------------------------------------------------------------------------------- 1 | import express from 'express'; 2 | import userRoutes from './routes/user.js'; 3 | import empresaRoutes from './routes/empresa.js'; 4 | import reclamacaoRoutes from './routes/reclamacao.js'; 5 | import { limiter } from './middlewares/rateLimiter.js'; 6 | import notFoundHandler from './middlewares/notFoundHandler.js'; 7 | import errorHandler from './middlewares/errorHandler.js'; 8 | import setupSwagger from '../swagger/swagger.js'; 9 | const app = express(); 10 | 11 | // Sem CORS pq o front é RN 12 | app.use(express.json()); 13 | app.use(express.urlencoded({ extended: true, limit: '2mb' })); 14 | app.use(limiter); 15 | app.use('/api/empresas', empresaRoutes); 16 | app.use('/api/users', userRoutes); 17 | app.use('/api/reclamacoes', reclamacaoRoutes); 18 | setupSwagger(app); 19 | app.use(notFoundHandler); 20 | app.use(errorHandler); 21 | export default app; 22 | -------------------------------------------------------------------------------- /app.json: -------------------------------------------------------------------------------- 1 | { 2 | "expo": { 3 | "name": "desatende", 4 | "slug": "desatende", 5 | "version": "1.0.0", 6 | "orientation": "portrait", 7 | "userInterfaceStyle": "light", 8 | "newArchEnabled": true, 9 | "scheme": "desatende", 10 | "splash": { 11 | "image": "./assets/splash.png", 12 | "resizeMode": "contain", 13 | "backgroundColor": "#ffffff" 14 | }, 15 | "assetBundlePatterns": ["**/*"], 16 | "ios": { 17 | "supportsTablet": true 18 | }, 19 | "android": { 20 | "adaptiveIcon": { 21 | "foregroundImage": "./assets/adaptive-icon.png", 22 | "backgroundColor": "#ffffff" 23 | } 24 | }, 25 | "web": { 26 | "favicon": "./assets/favicon.png" 27 | }, 28 | "plugins": ["expo-image-picker"], 29 | "extra": { 30 | "API_BASE_URL": "https://appdesatende.onrender.com/api" 31 | } 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /backend/src/middlewares/errorHandler.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Middleware global de tratamento de erros para Express.js 3 | * - Sempre retorna status e mensagem amigável ao usuário 4 | * - Loga detalhes do erro no servidor para análise posterior 5 | * - Não expõe informações sensíveis na resposta da API 6 | * - 7 | */ 8 | 9 | export default function errorHandler(err, req, res, next) { 10 | // Se já enviou resposta, só repassa o erro 11 | if (res.headersSent) return next(err); 12 | 13 | // Log detalhado no servidor 14 | console.error(`--- ERRO [${new Date().toISOString()}] ---`); 15 | console.error(`Rota: ${req.method} ${req.originalUrl}`); 16 | console.error(`Corpo da requisição: ${JSON.stringify(req.body)}`); 17 | console.error(`Erro:`, err); 18 | 19 | res.status(err.status || 500).json({ 20 | mensagem: 21 | err.message || 'Erro interno do servidor. Tente novamente mais tarde.', 22 | }); 23 | } 24 | -------------------------------------------------------------------------------- /app/src/hooks/useReclamacoesRecebidas.js: -------------------------------------------------------------------------------- 1 | import { useEffect } from 'react'; 2 | import { buscarReclamacoesRecebidas } from '../api/reclamacao.js'; 3 | import { useRefresh } from './useRefresh'; 4 | 5 | /** 6 | * HOOK DE RECLAMAÇÕES RECEBIDAS 7 | * 8 | * Hook especializado para buscar reclamações recebidas por empresas 9 | * Refatorado para usar o hook genérico useRefresh 10 | * 11 | * @returns {Object} - { reclamacoes, carregando, refresh } 12 | */ 13 | export function useReclamacoesRecebidas() { 14 | // Usa o hook genérico com a função específica de buscar reclamações 15 | const { 16 | data: reclamacoes, 17 | loading: carregando, 18 | refresh, 19 | } = useRefresh(buscarReclamacoesRecebidas); 20 | 21 | // Carrega dados automaticamente na primeira renderização 22 | useEffect(() => { 23 | refresh(); 24 | }, [refresh]); 25 | 26 | return { 27 | reclamacoes, 28 | carregando, 29 | refresh, 30 | }; 31 | } 32 | -------------------------------------------------------------------------------- /app/src/hooks/useFeedback.js: -------------------------------------------------------------------------------- 1 | import { useState } from 'react'; 2 | import { Alert } from 'react-native'; 3 | 4 | export function useFeedback() { 5 | const [loading, setLoading] = useState(false); 6 | const [success, setSuccess] = useState(null); // mensagem de sucesso 7 | const [error, setError] = useState(null); // mensagem de erro 8 | 9 | function showSuccess(msg) { 10 | setSuccess(msg); 11 | setError(null); 12 | setLoading(false); 13 | Alert.alert('Sucesso', msg); 14 | } 15 | 16 | function showError(msg) { 17 | setError(msg); 18 | setSuccess(null); 19 | setLoading(false); 20 | Alert.alert('Erro', msg); 21 | } 22 | 23 | function resetFeedback() { 24 | setSuccess(null); 25 | setError(null); 26 | setLoading(false); 27 | } 28 | 29 | return { 30 | loading, 31 | setLoading, 32 | success, 33 | setSuccess, 34 | error, 35 | setError, 36 | showSuccess, 37 | showError, 38 | resetFeedback, 39 | }; 40 | } 41 | -------------------------------------------------------------------------------- /app/components/HeaderTitulo.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { View, Text, StyleSheet } from 'react-native'; 3 | 4 | export default function HeaderTitulo({ titulo, tamanho = 32 }) { 5 | return ( 6 | 7 | 8 | {titulo} 9 | 10 | 11 | ); 12 | } 13 | 14 | const estilos = StyleSheet.create({ 15 | headerContainer: { 16 | paddingTop: 38, 17 | paddingBottom: 18, 18 | backgroundColor: 'transparent', 19 | alignItems: 'center', 20 | borderBottomWidth: 0, 21 | marginBottom: 2, 22 | }, 23 | headerTitulo: { 24 | fontSize: 32, 25 | fontWeight: '900', 26 | color: '#D84040', 27 | letterSpacing: 2.5, 28 | textAlign: 'center', 29 | textShadowColor: 'rgba(216, 64, 64, 0.3)', 30 | textShadowOffset: { width: 0, height: 3 }, 31 | textShadowRadius: 8, 32 | textTransform: 'uppercase', 33 | marginBottom: 4, 34 | }, 35 | }); 36 | -------------------------------------------------------------------------------- /backend/src/middlewares/auth.js: -------------------------------------------------------------------------------- 1 | import jwt from 'jsonwebtoken'; 2 | import User from '../models/User.js'; 3 | import Empresa from '../models/Empresa.js'; 4 | 5 | export default async function auth(req, res, next) { 6 | const authHeader = req.headers.authorization; 7 | if (!authHeader || !authHeader.startsWith('Bearer ')) { 8 | return res.status(401).json({ msg: 'Token não fornecido.' }); 9 | } 10 | const token = authHeader.split(' ')[1]; 11 | try { 12 | const decoded = jwt.verify(token, process.env.JWT_SECRET); 13 | 14 | // Tenta buscar como usuário 15 | let user = await User.findById(decoded.id); 16 | if (!user) { 17 | // Se não for user, tenta buscar como empresa 18 | user = await Empresa.findById(decoded.id); 19 | if (!user) 20 | return res.status(401).json({ msg: 'Usuário/Empresa não encontrado.' }); 21 | } 22 | 23 | req.user = user; 24 | next(); 25 | } catch (err) { 26 | return res.status(401).json({ msg: 'Token inválido.' }); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /backend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "backend", 3 | "version": "1.0.0", 4 | "main": "src/app.js", 5 | "type": "module", 6 | "scripts": { 7 | "start": "node --watch server.js", 8 | "dev": "nodemon --trace-warnings server.js", 9 | "test": "npx poku tests", 10 | "test:auth": "npx poku tests/auth.test.js", 11 | "test:validators": "npx poku tests/validators.test.js", 12 | "test:reclamacao": "npx poku tests/reclamacao.test.js", 13 | "test:watch": "npx poku tests --watch" 14 | }, 15 | "dependencies": { 16 | "bcrypt": "^6.0.0", 17 | "dotenv": "^16.5.0", 18 | "express": "^4.18.2", 19 | "express-rate-limit": "^7.5.0", 20 | "joi": "^17.13.3", 21 | "jsonwebtoken": "^9.0.2", 22 | "mongodb": "^6.16.0", 23 | "mongoose": "^7.6.1", 24 | "multer": "^2.0.1", 25 | "swagger-ui-express": "^5.0.1" 26 | }, 27 | "devDependencies": { 28 | "mongodb-memory-server": "^10.1.4", 29 | "nodemon": "^3.0.1", 30 | "poku": "^3.0.2", 31 | "supertest": "^7.1.1" 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /app/components/AuthModal.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Modal, View, StyleSheet } from 'react-native'; 3 | 4 | const CORES = { 5 | branco: '#FFF', 6 | }; 7 | 8 | const estilos = StyleSheet.create({ 9 | modalFundo: { 10 | flex: 1, 11 | justifyContent: 'center', 12 | alignItems: 'center', 13 | padding: 15, 14 | }, 15 | modalConteudo: { 16 | width: '100%', 17 | backgroundColor: CORES.branco, 18 | borderRadius: 12, 19 | padding: 20, 20 | }, 21 | }); 22 | 23 | export default function AuthModal({ 24 | visible, 25 | onRequestClose, 26 | renderizarFormulario, 27 | }) { 28 | return ( 29 | 35 | {/* Fundo semi-transparente para o modal */} 36 | 37 | {/* Conteúdo do modal */} 38 | {renderizarFormulario()} 39 | 40 | 41 | ); 42 | } 43 | -------------------------------------------------------------------------------- /app/src/hooks/useImagePicker.js: -------------------------------------------------------------------------------- 1 | import { useState } from 'react'; 2 | import * as ImagePicker from 'expo-image-picker'; 3 | import { Alert } from 'react-native'; 4 | 5 | export function useImagePicker() { 6 | const [imagem, setImagem] = useState(null); 7 | 8 | const selecionarImagem = async () => { 9 | try { 10 | const { status } = 11 | await ImagePicker.requestMediaLibraryPermissionsAsync(); 12 | if (status !== 'granted') { 13 | Alert.alert( 14 | 'Permissão necessária', 15 | 'Precisamos de permissão para acessar suas fotos.' 16 | ); 17 | return; 18 | } 19 | const resultado = await ImagePicker.launchImageLibraryAsync({ 20 | mediaTypes: ImagePicker.MediaTypeOptions.Images, 21 | allowsEditing: true, 22 | aspect: [4, 3], 23 | quality: 0.8, 24 | }); 25 | if ( 26 | !resultado.canceled && 27 | resultado.assets && 28 | resultado.assets.length > 0 29 | ) { 30 | setImagem(resultado.assets[0].uri); 31 | } 32 | } catch (erro) { 33 | Alert.alert('Erro', 'Não foi possível selecionar a imagem.'); 34 | } 35 | }; 36 | 37 | return { imagem, setImagem, selecionarImagem }; 38 | } 39 | -------------------------------------------------------------------------------- /backend/src/controllers/authUser.Controller.js: -------------------------------------------------------------------------------- 1 | import User from '../models/User.js'; 2 | 3 | export const loginUser = async (req, res) => { 4 | try { 5 | const { email, senha } = req.body; 6 | if (!email || !senha) { 7 | return res 8 | .status(400) 9 | .json({ message: 'Email e senha são obrigatórios.' }); 10 | } 11 | 12 | // Busca a user pelo email e inclui a senha (select: false no schema) 13 | const user = await User.findOne({ email }).select('+senha'); 14 | if (!user) { 15 | return res.status(401).json({ message: 'Email ou senha inválidos.' }); 16 | } 17 | 18 | // Verifica a senha 19 | const senhaCorreta = await user.verificaSenha(senha); 20 | if (!senhaCorreta) { 21 | return res.status(401).json({ message: 'Email ou senha inválidos.' }); 22 | } 23 | 24 | // Gera o token JWT usando o método do model 25 | const token = user.gerarTokenJWT(); 26 | 27 | // Remove a senha do objeto retornado 28 | const userRetorno = user.toJSON(); 29 | 30 | return res.status(200).json({ 31 | user: userRetorno, 32 | token, 33 | message: 'Login realizado com sucesso!', 34 | }); 35 | } catch (error) { 36 | return res 37 | .status(500) 38 | .json({ message: 'Erro ao fazer login.', error: error.message }); 39 | } 40 | }; 41 | -------------------------------------------------------------------------------- /app/src/hooks/useRefresh.js: -------------------------------------------------------------------------------- 1 | import { useState, useCallback } from 'react'; 2 | 3 | export function useRefresh(fetchFunction) { 4 | const [data, setData] = useState([]); 5 | const [loading, setLoading] = useState(false); 6 | const [error, setError] = useState(null); 7 | 8 | const refresh = useCallback(async () => { 9 | if (!fetchFunction || typeof fetchFunction !== 'function') { 10 | console.warn('useRefresh: fetchFunction deve ser uma função válida'); 11 | return; 12 | } 13 | 14 | setLoading(true); 15 | setError(null); 16 | 17 | try { 18 | const result = await fetchFunction(); 19 | setData(result); 20 | } catch (err) { 21 | console.error('Erro no refresh:', err); 22 | setError(err); 23 | } finally { 24 | setLoading(false); 25 | } 26 | }, [fetchFunction]); 27 | 28 | return { 29 | data, 30 | loading, 31 | refresh, 32 | error, 33 | }; 34 | } 35 | 36 | export function useSimpleRefresh(refreshCallback) { 37 | const refresh = useCallback(async () => { 38 | if (refreshCallback && typeof refreshCallback === 'function') { 39 | try { 40 | await refreshCallback(); 41 | } catch (error) { 42 | console.error('Erro no refresh simples:', error); 43 | } 44 | } 45 | }, [refreshCallback]); 46 | 47 | return refresh; 48 | } 49 | -------------------------------------------------------------------------------- /backend/src/controllers/authEmpresa.Controller.js: -------------------------------------------------------------------------------- 1 | import Empresa from '../models/Empresa.js'; 2 | 3 | export const loginEmpresa = async (req, res) => { 4 | try { 5 | const { email, senha } = req.body; 6 | if (!email || !senha) { 7 | return res 8 | .status(400) 9 | .json({ message: 'Email e senha são obrigatórios.' }); 10 | } 11 | 12 | // Busca a empresa pelo email e inclui a senha (select: false no schema) 13 | const empresa = await Empresa.findOne({ email }).select('+senha'); 14 | if (!empresa) { 15 | return res.status(401).json({ message: 'Email ou senha inválidos.' }); 16 | } 17 | 18 | // Verifica a senha 19 | const senhaCorreta = await empresa.verificaSenha(senha); 20 | if (!senhaCorreta) { 21 | return res.status(401).json({ message: 'Email ou senha inválidos.' }); 22 | } 23 | 24 | // Gera o token JWT usando o método do model 25 | const token = empresa.gerarTokenJWT(); 26 | 27 | // Remove a senha do objeto retornado 28 | const empresaRetorno = empresa.toJSON(); 29 | 30 | return res.status(200).json({ 31 | empresa: empresaRetorno, 32 | token, 33 | message: 'Login realizado com sucesso!', 34 | }); 35 | } catch (error) { 36 | return res 37 | .status(500) 38 | .json({ message: 'Erro ao fazer login.', error: error.message }); 39 | } 40 | }; 41 | -------------------------------------------------------------------------------- /app/src/api/auth.js: -------------------------------------------------------------------------------- 1 | import api from './axios'; 2 | import AsyncStorage from '@react-native-async-storage/async-storage'; 3 | 4 | // Cadastro de usuário 5 | export const cadastrarUsuario = async (dados) => { 6 | const response = await api.post('/users/cadastrar', dados); 7 | return response.data; 8 | }; 9 | 10 | // Login de usuário 11 | export const loginUsuario = async (dados) => { 12 | const response = await api.post('/users/login', dados); 13 | return response.data; 14 | }; 15 | 16 | // Login de empresa 17 | export const loginEmpresa = async (dados) => { 18 | const response = await api.post('/empresas/login', dados); 19 | return response.data; 20 | }; 21 | 22 | // Cadastro de empresa 23 | export const cadastrarEmpresa = async (dados) => { 24 | const response = await api.post('/empresas/cadastrar', dados); 25 | return response.data; 26 | }; 27 | 28 | // Utilitários para token JWT 29 | export const salvarToken = async (token) => { 30 | await AsyncStorage.setItem('token', token); 31 | }; 32 | 33 | // FUNÇÃO COMPLETA DE AUTENTICAÇÃO 34 | export const salvarDadosAutenticacao = async (token, tipo) => { 35 | await Promise.all([ 36 | AsyncStorage.setItem('token', token), 37 | AsyncStorage.setItem('tipo', tipo), 38 | ]); 39 | }; 40 | 41 | // FUNÇÃO DE LOGOUT COMPLETA 42 | export const limparDadosAutenticacao = async () => { 43 | await Promise.all([ 44 | AsyncStorage.removeItem('token'), 45 | AsyncStorage.removeItem('tipo'), 46 | ]); 47 | }; 48 | -------------------------------------------------------------------------------- /backend/src/routes/reclamacao.js: -------------------------------------------------------------------------------- 1 | import express from 'express'; 2 | import { 3 | criarReclamacao, 4 | listarReclamacoesPorUsuario, 5 | listarReclamacoesPorEmpresa, 6 | getReclamacaoPorId, 7 | editarReclamacao, 8 | deletarReclamacao, 9 | responderReclamacao, 10 | removerResposta, 11 | avaliarReclamacao, 12 | } from '../controllers/reclamacao.Controller.js'; 13 | import auth from '../middlewares/auth.js'; 14 | import upload from '../middlewares/upload.js'; 15 | import { validateBody } from '../middlewares/validate.js'; 16 | import { 17 | criarReclamacaoSchema, 18 | responderReclamacaoSchema, 19 | avaliarReclamacaoSchema, 20 | } from '../validators/reclamacaoValidators.js'; 21 | 22 | const router = express.Router(); 23 | 24 | // Rotas principais 25 | router.use(auth); 26 | 27 | router.post( 28 | '/', 29 | upload.single('imagem'), 30 | validateBody(criarReclamacaoSchema), 31 | criarReclamacao 32 | ); 33 | router.get('/meu-perfil', listarReclamacoesPorUsuario); 34 | 35 | router.get('/empresa', listarReclamacoesPorEmpresa); 36 | router.get('/:id', getReclamacaoPorId); 37 | router.patch('/:id', editarReclamacao); 38 | router.delete('/:id', deletarReclamacao); 39 | router.patch( 40 | '/:id/responder', 41 | validateBody(responderReclamacaoSchema), 42 | responderReclamacao 43 | ); 44 | router.delete('/:id/remover-resposta', removerResposta); 45 | 46 | router.post( 47 | '/:id/avaliar', 48 | validateBody(avaliarReclamacaoSchema), 49 | avaliarReclamacao 50 | ); 51 | 52 | export default router; 53 | -------------------------------------------------------------------------------- /backend/src/controllers/user.Controller.js: -------------------------------------------------------------------------------- 1 | import User from '../models/User.js'; 2 | 3 | export const cadastrarUsuario = async (req, res) => { 4 | try { 5 | const { nome, email, senha } = req.body; 6 | if (!nome || !email || !senha) { 7 | return res 8 | .status(400) 9 | .json({ message: 'Nome, email e senha são obrigatórios.' }); 10 | } 11 | 12 | // Verifica se já existe usuário com o mesmo email 13 | const usuarioExistente = await User.findOne({ email }); 14 | if (usuarioExistente) { 15 | return res 16 | .status(409) 17 | .json({ message: 'Já existe um usuário com este email.' }); 18 | } 19 | 20 | // Cria o usuário 21 | const novoUsuario = new User({ nome, email, senha }); 22 | await novoUsuario.save(); 23 | 24 | // Remove a senha do retorno 25 | const usuarioRetorno = novoUsuario.toJSON(); 26 | 27 | return res.status(201).json({ 28 | user: usuarioRetorno, 29 | message: 'Usuário cadastrado com sucesso!', 30 | }); 31 | } catch (error) { 32 | return res 33 | .status(500) 34 | .json({ message: 'Erro ao cadastrar usuário.', error: error.message }); 35 | } 36 | }; 37 | 38 | export const listarUsuarios = async (req, res) => { 39 | try { 40 | const usuarios = await User.find().select('-senha'); 41 | return res.status(200).json({ usuarios }); 42 | } catch (error) { 43 | return res.status(500).json({ 44 | message: 'Erro ao buscar empresas.', 45 | error: error.message, 46 | }); 47 | } 48 | }; 49 | -------------------------------------------------------------------------------- /app/components/CustomButton.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Pressable, View, Text, StyleSheet } from 'react-native'; 3 | 4 | const CORES = { 5 | corPrimaria: '#8be9fd', 6 | corPrimariaEscura: '#6be7fc', 7 | textoPrincipal: 'white', 8 | }; 9 | 10 | const estilos = StyleSheet.create({ 11 | botao: { 12 | backgroundColor: CORES.corPrimaria, 13 | borderRadius: 25, 14 | paddingVertical: 14, 15 | paddingHorizontal: 20, 16 | flexDirection: 'row', 17 | justifyContent: 'center', 18 | alignItems: 'center', 19 | marginTop: 10, 20 | marginBottom: 10, 21 | minWidth: 70, 22 | }, 23 | botaoPressionado: { 24 | opacity: 0.7, 25 | }, 26 | linhaBotao: { 27 | flexDirection: 'row', 28 | alignItems: 'center', 29 | justifyContent: 'center', 30 | width: '100%', 31 | }, 32 | textoBotao: { 33 | color: CORES.textoPrincipal, 34 | fontWeight: 'bold', 35 | fontSize: 16, 36 | textAlign: 'center', 37 | }, 38 | }); 39 | 40 | export default function CustomButton({ 41 | title, 42 | onPress, 43 | disabled, 44 | height, 45 | width, 46 | cor, 47 | }) { 48 | return ( 49 | [ 51 | estilos.botao, 52 | pressed && estilos.botaoPressionado, 53 | disabled && { opacity: 0.5 }, 54 | height && { height }, 55 | width && { width }, 56 | cor && { backgroundColor: cor }, 57 | ]} 58 | onPress={onPress} 59 | disabled={disabled} 60 | > 61 | 62 | {title} 63 | 64 | 65 | ); 66 | } 67 | -------------------------------------------------------------------------------- /backend/src/controllers/empresa.Controller.js: -------------------------------------------------------------------------------- 1 | import Empresa from '../models/Empresa.js'; 2 | 3 | export const cadastrarEmpresa = async (req, res) => { 4 | try { 5 | const { nome, email, senha } = req.body; 6 | if (!nome || !email || !senha) { 7 | return res.status(400).json({ 8 | message: 'O nome, email e senha não podem ficar em branco', 9 | }); 10 | } 11 | const empresaJaExistente = await Empresa.findOne({ email }); 12 | if (empresaJaExistente) { 13 | return res 14 | .status(409) 15 | .json({ message: 'Já existe um usuário com este email.' }); 16 | } 17 | const novaEmpresa = new Empresa({ nome, email, senha }); 18 | await novaEmpresa.save(); 19 | const empresaRetorno = novaEmpresa.toJSON(); 20 | 21 | return res.status(201).json({ 22 | empresa: empresaRetorno, 23 | message: 'Empresa cadastrada com sucesso!', 24 | }); 25 | } catch (error) { 26 | return res.status(500).json({ 27 | message: 'Deu ruim!', 28 | error: error.message, 29 | }); 30 | } 31 | }; 32 | 33 | export const listarEmpresas = async (req, res) => { 34 | try { 35 | const empresas = await Empresa.find().select('-senha'); 36 | return res.status(200).json({ empresas }); 37 | } catch (error) { 38 | return res.status(500).json({ 39 | message: 'Erro ao buscar empresas.', 40 | error: error.message, 41 | }); 42 | } 43 | }; 44 | 45 | export const buscarEmpresaPorId = async (req, res) => { 46 | try { 47 | const { id } = req.params; 48 | const empresa = await Empresa.findById(id); 49 | if (!empresa) { 50 | return res.status(404).json({ message: 'Empresa não encontrada.' }); 51 | } 52 | return res.status(200).json(empresa); 53 | } catch (error) { 54 | return res 55 | .status(500) 56 | .json({ message: 'Erro ao buscar empresa.', error: error.message }); 57 | } 58 | }; 59 | -------------------------------------------------------------------------------- /app/estilos/estilosLogin.js: -------------------------------------------------------------------------------- 1 | import { StyleSheet } from 'react-native'; 2 | 3 | // Variáveis de cores padronizadas 4 | const CORES = { 5 | // BACKGROUND 6 | fundoPrincipal: '#1A1A1D', // Fundo principal escuro do app 7 | 8 | // Cores principais 9 | corPrimaria: '#D84040', // Vermelho usado em todo o app 10 | corPrimariaEscura: '#A31D1D', // Vermelho mais escuro 11 | 12 | // Cores de texto 13 | textoPrincipal: '#ECDCBF', // Cor bege/creme para textos 14 | textoEscuro: '#333', // Cor escura para textos em fundos claros 15 | }; 16 | 17 | const estilos = StyleSheet.create({ 18 | container: { 19 | flex: 1, 20 | backgroundColor: CORES.fundoPrincipal, 21 | alignItems: 'center', 22 | justifyContent: 'center', 23 | padding: 20, 24 | }, 25 | // Estilo do título 26 | titulo: { 27 | fontSize: 34, 28 | fontWeight: 'bold', 29 | marginBottom: 8, 30 | textAlign: 'center', 31 | color: CORES.textoPrincipal, 32 | }, 33 | // Estilo para a palavra diferenciada no título 34 | tituloDiferente: { 35 | color: CORES.corPrimaria, 36 | }, 37 | // Estilo do subtítulo 38 | subtitulo: { 39 | fontSize: 18, 40 | textAlign: 'center', 41 | color: CORES.textoPrincipal, 42 | marginHorizontal: 10, 43 | marginBottom: 20, 44 | }, 45 | // Estilo para a parte diferenciada do subtítulo 46 | subdiferente: { 47 | color: CORES.corPrimaria, 48 | fontWeight: 'bold', 49 | fontSize: 18, 50 | marginHorizontal: 10, 51 | marginBottom: 20, 52 | }, 53 | // Estilo da animação 54 | animacao: { 55 | width: '100%', 56 | height: 200, 57 | marginBottom: 50, 58 | }, 59 | // Estilo do subtítulo2 60 | subtitulo2: { 61 | fontSize: 16, 62 | textAlign: 'center', 63 | color: CORES.textoPrincipal, 64 | marginHorizontal: 20, 65 | marginBottom: 10, 66 | }, 67 | esqueci: { 68 | color: CORES.corPrimaria, 69 | }, 70 | }); 71 | 72 | export default estilos; 73 | -------------------------------------------------------------------------------- /app/components/Formulario.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { ScrollView, Text, TextInput, StyleSheet } from 'react-native'; 3 | import CustomButton from './CustomButton'; 4 | 5 | const CORES = { 6 | corPrimaria: '#D84040', 7 | corPrimariaEscura: '#A31D1D', 8 | textoPrincipal: '#ECDCBF', 9 | textoEscuro: '#333', 10 | branco: '#FFF', 11 | bordaInput: '#CCC', 12 | }; 13 | 14 | const estilos = StyleSheet.create({ 15 | formularioContainer: { 16 | alignItems: 'center', 17 | }, 18 | tituloFormulario: { 19 | fontSize: 25, 20 | fontWeight: 'bold', 21 | marginBottom: 20, 22 | color: CORES.corPrimaria, 23 | }, 24 | input: { 25 | width: '100%', 26 | borderWidth: 1, 27 | borderColor: CORES.bordaInput, 28 | borderRadius: 8, 29 | padding: 10, 30 | marginBottom: 15, 31 | color: CORES.textoEscuro, 32 | }, 33 | }); 34 | 35 | export default function Formulario({ 36 | titulo, 37 | campos, // array de objetos: { placeholder, value, onChangeText, ...props } 38 | botoes, // array de objetos: { title, onPress, ...props } 39 | children, 40 | corBotao, // Nova prop para cor dos botões 41 | }) { 42 | return ( 43 | 44 | {titulo} 45 | {campos.map((campo, idx) => ( 46 | 55 | ))} 56 | {children} 57 | {botoes.map((botao, idx) => ( 58 | 65 | ))} 66 | 67 | ); 68 | } 69 | -------------------------------------------------------------------------------- /app/src/hooks/useAuth.js: -------------------------------------------------------------------------------- 1 | import { useState, useEffect } from 'react'; 2 | import AsyncStorage from '@react-native-async-storage/async-storage'; 3 | import { 4 | limparDadosAutenticacao, 5 | salvarDadosAutenticacao, 6 | } from '../api/auth.js'; 7 | 8 | export function useAuth() { 9 | const [token, setToken] = useState(null); 10 | const [tipo, setTipo] = useState(null); // 'user' ou 'empresa' 11 | const [carregando, setCarregando] = useState(true); 12 | 13 | // FUNÇÃO PARA CARREGAR DADOS DO STORAGE 14 | const carregarDadosAutenticacao = async () => { 15 | try { 16 | setCarregando(true); 17 | const [tokenSalvo, tipoSalvo] = await Promise.all([ 18 | AsyncStorage.getItem('token'), 19 | AsyncStorage.getItem('tipo'), 20 | ]); 21 | 22 | setToken(tokenSalvo); 23 | setTipo(tipoSalvo); 24 | } catch (error) { 25 | console.error('Erro ao carregar dados de autenticação:', error); 26 | setToken(null); 27 | setTipo(null); 28 | } finally { 29 | setCarregando(false); 30 | } 31 | }; 32 | 33 | // FUNÇÃO DE LOGIN INTEGRADA 34 | const fazerLogin = async (token, tipo) => { 35 | try { 36 | await salvarDadosAutenticacao(token, tipo); 37 | setToken(token); 38 | setTipo(tipo); 39 | } catch (error) { 40 | console.error('Erro ao salvar dados de login:', error); 41 | throw error; 42 | } 43 | }; 44 | 45 | // FUNÇÃO DE LOGOUT INTEGRADA 46 | const fazerLogout = async () => { 47 | try { 48 | await limparDadosAutenticacao(); 49 | setToken(null); 50 | setTipo(null); 51 | } catch (error) { 52 | console.error('Erro ao limpar dados de logout:', error); 53 | throw error; 54 | } 55 | }; 56 | 57 | // FUNÇÃO PARA VERIFICAR SE ESTÁ AUTENTICADO 58 | const estaAutenticado = () => { 59 | return !!(token && tipo); 60 | }; 61 | 62 | // CARREGA DADOS AO INICIALIZAR 63 | useEffect(() => { 64 | carregarDadosAutenticacao(); 65 | }, []); 66 | 67 | return { 68 | token, 69 | tipo, 70 | carregando, 71 | estaAutenticado: estaAutenticado(), 72 | fazerLogin, 73 | fazerLogout, 74 | recarregar: carregarDadosAutenticacao, 75 | }; 76 | } 77 | -------------------------------------------------------------------------------- /app/components/LogoutButton.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { TouchableOpacity, Text, StyleSheet, Alert } from 'react-native'; 3 | import { useRouter } from 'expo-router'; 4 | import { useAuth } from '../src/hooks/useAuth'; 5 | const LogoutButton = ({ onLogout }) => { 6 | const router = useRouter(); 7 | const { fazerLogout } = useAuth(); 8 | 9 | const handleLogout = () => { 10 | Alert.alert('Confirmar Logout', 'Tem certeza que deseja sair?', [ 11 | { 12 | text: 'Cancelar', 13 | style: 'cancel', 14 | }, 15 | { 16 | text: 'Sair', 17 | style: 'destructive', 18 | onPress: async () => { 19 | try { 20 | // USANDO FUNÇÃO INTEGRADA DO HOOK QUE LIMPA STORAGE 21 | await fazerLogout(); 22 | 23 | // Executa função de logout passada como prop 24 | if (onLogout && typeof onLogout === 'function') { 25 | await onLogout(); 26 | } 27 | 28 | // Redireciona para tela inicial 29 | router.replace('/'); 30 | } catch (error) { 31 | console.error('Erro durante logout:', error); 32 | Alert.alert('Erro', 'Falha ao fazer logout. Tente novamente.'); 33 | } 34 | }, 35 | }, 36 | ]); 37 | }; 38 | 39 | return ( 40 | 45 | SAIR 46 | 47 | ); 48 | }; 49 | 50 | const styles = StyleSheet.create({ 51 | logoutButton: { 52 | backgroundColor: '#ff5555', 53 | paddingHorizontal: 12, 54 | paddingVertical: 8, 55 | borderRadius: 100, 56 | alignItems: 'center', 57 | justifyContent: 'center', 58 | minWidth: 30, 59 | shadowColor: '#000', 60 | shadowOffset: { 61 | width: 0, 62 | height: 0, 63 | }, 64 | shadowOpacity: 7, 65 | shadowRadius: 2, 66 | elevation: 2, // Android shadow 67 | }, 68 | logoutText: { 69 | color: '#f8f8f2', 70 | fontSize: 13, 71 | fontWeight: '600', 72 | letterSpacing: 0.5, 73 | }, 74 | }); 75 | 76 | export default LogoutButton; 77 | -------------------------------------------------------------------------------- /backend/src/models/User.js: -------------------------------------------------------------------------------- 1 | import mongoose from 'mongoose'; 2 | import bcrypt from 'bcrypt'; 3 | import jwt from 'jsonwebtoken'; 4 | 5 | const userSchema = new mongoose.Schema( 6 | { 7 | nome: { 8 | type: String, 9 | required: [true, 'O nome é obrigatório.'], 10 | }, 11 | 12 | email: { 13 | type: String, 14 | required: [true, 'O email é obrigatório.'], 15 | unique: true, // impede duplicidade no banco 16 | lowercase: true, 17 | match: [/^\S+@\S+\.\S+$/, 'Formato de e-mail inválido.'], // regex para validar formato de e-mail 18 | }, 19 | 20 | senha: { 21 | type: String, 22 | required: [true, 'A senha é obrigatória.'], 23 | minlength: [6, 'A senha deve ter no mínimo 6 caracteres.'], 24 | select: false, // por segurança, não retorna por padrão 25 | }, 26 | 27 | tipo: { 28 | type: String, 29 | enum: ['user'], // você pode expandir para ['user', 'admin', 'empresa'] depois 30 | default: 'user', 31 | }, 32 | }, 33 | { 34 | timestamps: true, // cria campos createdAt e updatedAt automaticamente 35 | } 36 | ); 37 | 38 | // Middleware: antes de salvar, faz o hash da senha 39 | userSchema.pre('save', async function (next) { 40 | if (!this.isModified('senha')) return next(); // só hash se for nova ou modificada 41 | 42 | try { 43 | const salt = await bcrypt.genSalt(10); 44 | this.senha = await bcrypt.hash(this.senha, salt); 45 | next(); 46 | } catch (error) { 47 | next(error); 48 | } 49 | }); 50 | 51 | // Método: verifica se a senha fornecida é igual à hashada 52 | userSchema.methods.verificaSenha = async function (senhaTexto) { 53 | return await bcrypt.compare(senhaTexto, this.senha); 54 | }; 55 | 56 | // Método: gera token JWT para o usuário atual 57 | userSchema.methods.gerarTokenJWT = function () { 58 | return jwt.sign( 59 | { id: this._id, email: this.email, tipo: this.tipo }, 60 | process.env.JWT_SECRET, // importante usar variável de ambiente 61 | { expiresIn: '7d' } // exemplo: 7 dias de validade 62 | ); 63 | }; 64 | 65 | // Remove a senha do JSON ao retornar o usuário 66 | userSchema.methods.toJSON = function () { 67 | const obj = this.toObject(); 68 | delete obj.senha; 69 | return obj; 70 | }; 71 | 72 | const User = mongoose.model('User', userSchema); 73 | 74 | export default User; 75 | -------------------------------------------------------------------------------- /backend/src/models/Empresa.js: -------------------------------------------------------------------------------- 1 | import mongoose from 'mongoose'; 2 | import bcrypt from 'bcrypt'; 3 | import jwt from 'jsonwebtoken'; 4 | 5 | // Schema da Empresa 6 | const empresaSchema = new mongoose.Schema( 7 | { 8 | nome: { 9 | type: String, 10 | required: [true, 'O nome é obrigatório.'], 11 | }, 12 | 13 | email: { 14 | type: String, 15 | required: [true, 'O email é obrigatório.'], 16 | unique: true, // Garante que cada empresa tenha um e-mail único 17 | lowercase: true, 18 | match: [/^\S+@\S+\.\S+$/, 'Formato de e-mail inválido.'], 19 | }, 20 | 21 | senha: { 22 | type: String, 23 | required: [true, 'A senha é obrigatória.'], 24 | minlength: [6, 'A senha deve ter no mínimo 6 caracteres.'], 25 | select: false, // Senha não aparece por padrão em queries 26 | }, 27 | 28 | tipo: { 29 | type: String, 30 | enum: ['empresa'], // Define que esse schema é apenas para 'empresa' 31 | default: 'empresa', 32 | }, 33 | }, 34 | { 35 | timestamps: true, // Cria campos createdAt e updatedAt automaticamente 36 | } 37 | ); 38 | 39 | // Middleware: gera o hash da senha antes de salvar 40 | empresaSchema.pre('save', async function (next) { 41 | if (!this.isModified('senha')) return next(); // Só hash se nova ou modificada 42 | 43 | try { 44 | const salt = await bcrypt.genSalt(10); 45 | this.senha = await bcrypt.hash(this.senha, salt); 46 | next(); 47 | } catch (error) { 48 | next(error); 49 | } 50 | }); 51 | 52 | // Método: verifica se a senha fornecida bate com o hash 53 | empresaSchema.methods.verificaSenha = async function (senhaTexto) { 54 | return await bcrypt.compare(senhaTexto, this.senha); 55 | }; 56 | 57 | // Método: gera um token JWT contendo dados essenciais da empresa 58 | empresaSchema.methods.gerarTokenJWT = function () { 59 | return jwt.sign( 60 | { 61 | id: this._id, 62 | email: this.email, 63 | tipo: this.tipo, 64 | }, 65 | process.env.JWT_SECRET, // chave secreta usada para assinar o token 66 | { expiresIn: '7d' } // validade do token 67 | ); 68 | }; 69 | 70 | // Remove o campo senha ao retornar o objeto como JSON 71 | empresaSchema.methods.toJSON = function () { 72 | const obj = this.toObject(); 73 | delete obj.senha; 74 | return obj; 75 | }; 76 | 77 | const Empresa = mongoose.model('Empresa', empresaSchema); 78 | 79 | export default Empresa; 80 | -------------------------------------------------------------------------------- /backend/src/validators/authValidators.js: -------------------------------------------------------------------------------- 1 | import Joi from 'joi'; 2 | 3 | // Validator para login (usuário e empresa) 4 | export const loginSchema = Joi.object({ 5 | email: Joi.string() 6 | .pattern(/^[^\s@]+@[^\s@]+\.[^\s@]+$/) 7 | .required() 8 | .messages({ 9 | 'string.pattern.base': 'Formato de e-mail inválido', 10 | 'any.required': 'O email é obrigatório', 11 | }), 12 | 13 | senha: Joi.string().min(6).required().messages({ 14 | 'string.min': 'A senha deve ter no mínimo 6 caracteres', 15 | 'any.required': 'A senha é obrigatória', 16 | }), 17 | }); 18 | 19 | // Validator para cadastro de usuário 20 | export const cadastroUsuarioSchema = Joi.object({ 21 | nome: Joi.string() 22 | .trim() 23 | .min(2) 24 | .pattern(/^[a-zA-ZÀ-ÿ\s]+$/) 25 | .required() 26 | .messages({ 27 | 'string.min': 'O nome deve ter no mínimo 2 caracteres', 28 | 'string.pattern.base': 'O nome deve conter apenas letras e espaços', 29 | 'any.required': 'O nome é obrigatório', 30 | }), 31 | 32 | email: Joi.string() 33 | .pattern(/^[^\s@]+@[^\s@]+\.[^\s@]+$/) 34 | .required() 35 | .messages({ 36 | 'string.pattern.base': 'Formato de e-mail inválido', 37 | 'any.required': 'O email é obrigatório', 38 | }), 39 | 40 | senha: Joi.string().min(6).required().messages({ 41 | 'string.min': 'A senha deve ter no mínimo 6 caracteres', 42 | 'any.required': 'A senha é obrigatória', 43 | }), 44 | 45 | confirmarSenha: Joi.string().valid(Joi.ref('senha')).required().messages({ 46 | 'any.only': 'As senhas não coincidem', 47 | 'any.required': 'A confirmação de senha é obrigatória', 48 | }), 49 | 50 | tipo: Joi.string().valid('user').default('user'), 51 | }); 52 | 53 | // Validator para cadastro de empresa 54 | export const cadastroEmpresaSchema = Joi.object({ 55 | nome: Joi.string() 56 | .trim() 57 | .min(2) 58 | .pattern(/^[a-zA-ZÀ-ÿ0-9\s\-\.]+$/) 59 | .required() 60 | .messages({ 61 | 'string.min': 'O nome deve ter no mínimo 2 caracteres', 62 | 'string.pattern.base': 63 | 'O nome deve conter apenas letras, números, espaços, hífen e ponto', 64 | 'any.required': 'O nome é obrigatório', 65 | }), 66 | 67 | email: Joi.string() 68 | .pattern(/^[^\s@]+@[^\s@]+\.[^\s@]+$/) 69 | .required() 70 | .messages({ 71 | 'string.pattern.base': 'Formato de e-mail inválido', 72 | 'any.required': 'O email é obrigatório', 73 | }), 74 | 75 | senha: Joi.string().min(6).required().messages({ 76 | 'string.min': 'A senha deve ter no mínimo 6 caracteres', 77 | 'any.required': 'A senha é obrigatória', 78 | }), 79 | 80 | tipo: Joi.string().valid('empresa').default('empresa'), 81 | }); 82 | -------------------------------------------------------------------------------- /app/perfil.jsx: -------------------------------------------------------------------------------- 1 | import { View, Text, FlatList, ActivityIndicator } from 'react-native'; 2 | import { useRouter } from 'expo-router'; 3 | import Rodape from './components/Rodape'; 4 | import { useMinhasReclamacoes } from './src/hooks/useMinhasReclamacoes'; 5 | import ReclamacaoItem from './components/ReclamacaoItem'; 6 | import HeaderTitulo from './components/HeaderTitulo'; 7 | import LogoutButton from './components/LogoutButton'; 8 | 9 | // PALETA DRACULA 10 | const CORES = { 11 | fundoPrincipal: '#282a36', // Dracula background 12 | corPrimaria: '#8be9fd', // Dracula cyan 13 | textoSuave: '#6272a4', // Dracula comment 14 | }; 15 | 16 | export default function PerfilScreen() { 17 | const router = useRouter(); 18 | 19 | /** 20 | * HOOK: Gerenciamento de reclamações 21 | * Inclui função de refresh para atualizar lista após avaliações 22 | */ 23 | const { reclamacoes, carregando, refresh } = useMinhasReclamacoes(); 24 | 25 | return ( 26 | 27 | 35 | 36 | 40 | 41 | 42 | 43 | 44 | {carregando ? ( 45 | 50 | ) : ( 51 | item._id} 54 | renderItem={({ item }) => ( 55 | 59 | )} 60 | contentContainerStyle={{ 61 | paddingBottom: 90, // espaço para o rodapé 62 | paddingTop: 10, 63 | paddingHorizontal: 8, 64 | }} 65 | showsVerticalScrollIndicator={false} 66 | ListEmptyComponent={ 67 | 74 | Nenhuma reclamação encontrada. 75 | 76 | } 77 | /> 78 | )} 79 | { 82 | if (destino === 'home') router.push('/home'); 83 | if (destino === 'perfil') router.push('/perfil'); 84 | }} 85 | /> 86 | 87 | ); 88 | } 89 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "desatende", 3 | "main": "expo-router/entry", 4 | "type": "module", 5 | "version": "1.0.0", 6 | "scripts": { 7 | "type": "module", 8 | "start": "expo start", 9 | "reset-project": "node ./scripts/reset-project.js", 10 | "android": "expo start --android", 11 | "ios": "expo start --ios", 12 | "web": "expo start --web", 13 | "test": "jest --watchAll", 14 | "lint": "expo lint" 15 | }, 16 | "jest": { 17 | "preset": "jest-expo" 18 | }, 19 | "dependencies": { 20 | "@expo/vector-icons": "^14.0.2", 21 | "@react-native-async-storage/async-storage": "^2.1.2", 22 | "@react-navigation/bottom-tabs": "^7.2.0", 23 | "@react-navigation/native": "^7.0.17", 24 | "@react-navigation/native-stack": "^7.3.1", 25 | "axios": "^1.9.0", 26 | "dotenv": "^16.5.0", 27 | "expo": "~52.0.39", 28 | "expo-blur": "~14.0.3", 29 | "expo-constants": "~17.0.8", 30 | "expo-font": "~13.0.4", 31 | "expo-haptics": "~14.0.1", 32 | "expo-image-picker": "~16.0.6", 33 | "expo-linear-gradient": "~14.0.2", 34 | "expo-linking": "~7.0.5", 35 | "expo-router": "~4.0.19", 36 | "expo-splash-screen": "~0.29.22", 37 | "expo-status-bar": "~2.0.1", 38 | "expo-symbols": "~0.2.2", 39 | "expo-system-ui": "~4.0.8", 40 | "expo-web-browser": "~14.0.2", 41 | "express": "^5.1.0", 42 | "jsonwebtoken": "^9.0.2", 43 | "lottie": "^0.0.1", 44 | "lottie-ios": "^4.5.1", 45 | "lottie-react-native": "^7.1.0", 46 | "mongodb": "^6.16.0", 47 | "mongoose": "^8.14.0", 48 | "react": "18.3.1", 49 | "react-dom": "18.3.1", 50 | "react-native": "0.76.7", 51 | "react-native-bottom-sheet": "^1.0.3", 52 | "react-native-gesture-handler": "~2.20.2", 53 | "react-native-reanimated": "~3.16.1", 54 | "react-native-safe-area-context": "^4.12.0", 55 | "react-native-screens": "~4.4.0", 56 | "react-native-svg": "15.8.0", 57 | "react-native-vector-icons": "^10.2.0", 58 | "react-native-web": "~0.19.13", 59 | "react-native-webview": "13.12.5" 60 | }, 61 | "devDependencies": { 62 | "@babel/core": "^7.25.2", 63 | "@types/jest": "^29.5.12", 64 | "@types/react": "~18.3.12", 65 | "@types/react-native": "^0.72.8", 66 | "@types/react-test-renderer": "^18.3.0", 67 | "jest": "^29.2.1", 68 | "jest-expo": "~52.0.6", 69 | "react-test-renderer": "18.3.1", 70 | "typescript": "^5.3.3" 71 | }, 72 | "private": true, 73 | "description": "This is an [Expo](https://expo.dev) project created with [`create-expo-app`](https://www.npmjs.com/package/create-expo-app).", 74 | "repository": { 75 | "type": "git", 76 | "url": "git+https://github.com/mmyersbyte/appdesatende.git" 77 | }, 78 | "keywords": [], 79 | "author": "", 80 | "license": "ISC", 81 | "bugs": { 82 | "url": "https://github.com/mmyersbyte/appdesatende/issues" 83 | }, 84 | "homepage": "https://github.com/mmyersbyte/appdesatende#readme" 85 | } 86 | -------------------------------------------------------------------------------- /backend/src/validators/reclamacaoValidators.js: -------------------------------------------------------------------------------- 1 | import Joi from 'joi'; 2 | 3 | export const criarReclamacaoSchema = Joi.object({ 4 | titulo: Joi.string().trim().min(5).max(100).required().messages({ 5 | 'string.min': 'O título deve ter no mínimo 5 caracteres', 6 | 'string.max': 'O título deve ter no máximo 100 caracteres', 7 | 'any.required': 'O título é obrigatório', 8 | }), 9 | 10 | descricao: Joi.string().trim().min(10).max(1000).required().messages({ 11 | 'string.min': 'A descrição deve ter no mínimo 10 caracteres', 12 | 'string.max': 'A descrição deve ter no máximo 1000 caracteres', 13 | 'any.required': 'A descrição é obrigatória', 14 | }), 15 | 16 | empresa: Joi.string() 17 | .pattern(/^[0-9a-fA-F]{24}$/) 18 | .required() 19 | .messages({ 20 | 'string.pattern.base': 'ID da empresa inválido', 21 | 'any.required': 'A empresa é obrigatória', 22 | }), 23 | 24 | // Campo contato - aceita qualquer formato, apenas obrigatório e até 100 caracteres 25 | contato: Joi.string().trim().max(100).required().messages({ 26 | 'string.max': 'Contato deve ter no máximo 100 caracteres', 27 | 'any.required': 'O contato é obrigatório', 28 | }), 29 | 30 | // Campo imagem - opcional, aceita formatos comuns 31 | imagem: Joi.object({ 32 | data: Joi.binary().optional(), 33 | contentType: Joi.string() 34 | .pattern(/^image\/(jpeg|jpg|png|gif|webp)$/) 35 | .optional() 36 | .messages({ 37 | 'string.pattern.base': 38 | 'Formato de imagem inválido. Use: jpeg, jpg, png, gif ou webp', 39 | }), 40 | }).optional(), 41 | }); 42 | 43 | // Validator para responder reclamação 44 | export const responderReclamacaoSchema = Joi.object({ 45 | texto: Joi.string().trim().min(10).max(1000).required().messages({ 46 | 'string.min': 'A resposta deve ter no mínimo 10 caracteres', 47 | 'string.max': 'A resposta deve ter no máximo 1000 caracteres', 48 | 'any.required': 'O texto da resposta é obrigatório', 49 | }), 50 | }); 51 | 52 | // Validator para avaliar reclamação 53 | export const avaliarReclamacaoSchema = Joi.object({ 54 | estrelas: Joi.number().integer().min(1).max(5).required().messages({ 55 | 'number.base': 'As estrelas devem ser um número', 56 | 'number.integer': 'As estrelas devem ser um número inteiro', 57 | 'number.min': 'A avaliação deve ter pelo menos 1 estrela', 58 | 'number.max': 'A avaliação deve ter no máximo 5 estrelas', 59 | 'any.required': 'A avaliação por estrelas é obrigatória', 60 | }), 61 | 62 | problemaResolvido: Joi.boolean().required().messages({ 63 | 'boolean.base': 'Problema resolvido deve ser verdadeiro ou falso', 64 | 'any.required': 'É obrigatório informar se o problema foi resolvido', 65 | }), 66 | 67 | comentario: Joi.string().trim().min(10).max(500).optional().messages({ 68 | 'string.min': 'O comentário deve ter no mínimo 10 caracteres', 69 | 'string.max': 'O comentário deve ter no máximo 500 caracteres', 70 | }), 71 | }); 72 | -------------------------------------------------------------------------------- /app/components/EmpresaItem.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { 3 | View, 4 | Text, 5 | Image, 6 | Pressable, 7 | ActivityIndicator, 8 | StyleSheet, 9 | } from 'react-native'; 10 | 11 | export default function EmpresaItem({ 12 | item, 13 | imageLoaded, 14 | onImageLoad, 15 | onPress, 16 | }) { 17 | return ( 18 | [styles.empresaItem, pressed && { opacity: 0.7 }]} 22 | > 23 | 24 | {!imageLoaded && ( 25 | 26 | {item.nome.charAt(0)} 27 | 32 | 33 | )} 34 | 42 | 43 | 48 | {item.nome} 49 | 50 | 51 | ); 52 | } 53 | 54 | const styles = StyleSheet.create({ 55 | empresaItem: { 56 | marginRight: 29, 57 | alignItems: 'center', 58 | width: 100, 59 | backgroundColor: '#23272a', 60 | borderRadius: 28, 61 | paddingVertical: 10, 62 | borderWidth: 1.5, 63 | borderColor: '#102E50', 64 | shadowColor: '#000', 65 | shadowOpacity: 0.8, 66 | shadowRadius: 10, 67 | elevation: 2, 68 | marginBottom: 4, 69 | }, 70 | empresaImagemContainer: { 71 | borderRadius: 50, 72 | overflow: 'hidden', 73 | marginBottom: 8, 74 | backgroundColor: '#232326', 75 | width: 64, 76 | height: 64, 77 | alignItems: 'center', 78 | justifyContent: 'center', 79 | }, 80 | empresaImagem: { 81 | borderRadius: 32, 82 | width: 64, 83 | height: 64, 84 | }, 85 | placeholderContainer: { 86 | position: 'absolute', 87 | justifyContent: 'center', 88 | alignItems: 'center', 89 | backgroundColor: '#232326', 90 | borderRadius: 32, 91 | width: 64, 92 | height: 64, 93 | }, 94 | placeholderText: { 95 | fontSize: 22, 96 | fontWeight: 'bold', 97 | color: '#D84040', 98 | }, 99 | loadingIndicator: { 100 | position: 'absolute', 101 | bottom: 6, 102 | }, 103 | empresaNome: { 104 | fontSize: 14, 105 | fontWeight: '600', 106 | color: '#fff', 107 | textAlign: 'center', 108 | width: '100%', 109 | paddingHorizontal: 2, 110 | marginTop: 2, 111 | height: 36, 112 | }, 113 | }); 114 | -------------------------------------------------------------------------------- /app/estilos/estilosHome.js: -------------------------------------------------------------------------------- 1 | import { StyleSheet } from 'react-native'; 2 | 3 | const CORES = { 4 | fundoPrincipal: '#1A1A1D', // Background alterado 5 | fundoClaro: '#44475a', // Dracula current line 6 | 7 | corPrimaria: '#8be9fd', // Dracula cyan - título principal 8 | bordaPrincipal: '#bd93f9', // Dracula purple - bordas especiais 9 | 10 | texto: '#f8f8f2', // Dracula foreground 11 | textoClaro: 'rgba(248, 248, 242, 0.95)', // Dracula foreground 95% 12 | textoMaisClaro: 'rgba(248, 248, 242, 0.85)', // Dracula foreground 85% 13 | textoSuave: 'rgba(255, 255, 255, 0.7)', // Texto suave 14 | textoPlaceholder: '#6272a4', // Dracula comment para placeholders 15 | }; 16 | 17 | const estilos = StyleSheet.create({ 18 | container: { 19 | flex: 1, 20 | backgroundColor: CORES.fundoPrincipal, 21 | paddingTop: 32, 22 | paddingHorizontal: 12, 23 | paddingBottom: 70, 24 | }, 25 | /* PESQUISA EMPRESAS - OTIMIZADA */ 26 | searchSection: { 27 | marginBottom: 12, 28 | }, 29 | labelPesquisa: { 30 | fontSize: 28, 31 | fontWeight: 'bold', 32 | marginBottom: 6, 33 | textAlign: 'center', 34 | color: CORES.corPrimaria, 35 | letterSpacing: 1.2, 36 | }, 37 | searchContainer: { 38 | flexDirection: 'row', 39 | alignItems: 'center', 40 | backgroundColor: CORES.fundoClaro, 41 | borderColor: 'transparent', 42 | borderWidth: 0, 43 | borderRadius: 12, 44 | paddingHorizontal: 16, 45 | paddingVertical: 6, 46 | shadowColor: '#000', 47 | shadowOpacity: 0.15, 48 | shadowRadius: 10, 49 | elevation: 5, 50 | }, 51 | searchIcon: { 52 | marginRight: 10, 53 | color: CORES.textoPlaceholder, 54 | }, 55 | searchInput: { 56 | flex: 1, 57 | height: 40, 58 | color: CORES.texto, 59 | fontSize: 15, 60 | backgroundColor: 'transparent', 61 | fontWeight: '500', 62 | letterSpacing: 0.3, 63 | paddingLeft: 4, 64 | }, 65 | /* BANNER PRINCIPAL - OTIMIZADO PARA PROXIMIDADE */ 66 | bannerContainer: { 67 | marginBottom: 14, 68 | alignItems: 'center', 69 | shadowColor: '#000', 70 | shadowOpacity: 0.2, 71 | shadowRadius: 12, 72 | elevation: 6, 73 | }, 74 | bannerImagem: { 75 | width: '100%', 76 | height: 280, 77 | borderRadius: 18, 78 | }, 79 | textoExplicativo: { 80 | fontSize: 18, 81 | fontWeight: '700', 82 | color: '#bd93f9', 83 | lineHeight: 26, 84 | textAlign: 'center', 85 | marginBottom: 16, 86 | marginHorizontal: 12, 87 | paddingHorizontal: 8, 88 | textShadowColor: 'rgba(189, 147, 249, 0.2)', 89 | textShadowOffset: { width: 0, height: 2 }, 90 | textShadowRadius: 4, 91 | letterSpacing: 0.8, 92 | }, 93 | 94 | empresasSection: { 95 | marginBottom: 20, 96 | flex: 1, 97 | }, 98 | empresasSubtitulo: { 99 | fontSize: 16, 100 | textAlign: 'center', 101 | fontWeight: 'bold', 102 | color: '#D84040', 103 | marginBottom: 14, 104 | letterSpacing: 0.3, 105 | paddingHorizontal: 4, 106 | }, 107 | empresasListContainer: { 108 | paddingRight: 12, 109 | paddingLeft: 4, 110 | paddingBottom: 8, 111 | }, 112 | }); 113 | 114 | export default estilos; 115 | -------------------------------------------------------------------------------- /app/components/Rodape.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { View, Text, Pressable, StyleSheet } from 'react-native'; 3 | import { FontAwesome } from '@expo/vector-icons'; 4 | 5 | // PALETA PARA RODAPE 6 | const CORES = { 7 | fundoPrincipal: '#1A1A1D', 8 | corPrimaria: '#F2F2F2', 9 | textoPrincipal: '#f8f8f2', 10 | textoSecundario: 'gray', 11 | bordaSutil: '#44475a', 12 | }; 13 | 14 | const estilos = StyleSheet.create({ 15 | rodape: { 16 | position: 'absolute', 17 | bottom: 0, 18 | left: 5, 19 | right: 5, 20 | width: '105%', 21 | height: 70, 22 | flexDirection: 'row', 23 | borderTopWidth: 2, 24 | borderTopColor: CORES.bordaSutil, 25 | backgroundColor: CORES.fundoPrincipal, 26 | paddingHorizontal: 0, 27 | zIndex: 1000, 28 | }, 29 | itemRodape: { 30 | width: '50%', 31 | height: 70, 32 | alignItems: 'center', 33 | justifyContent: 'center', 34 | paddingVertical: 12, 35 | }, 36 | itemContainer: { 37 | width: 60, 38 | height: 46, 39 | alignItems: 'center', 40 | justifyContent: 'center', 41 | }, 42 | iconeFixo: { 43 | textAlign: 'center', 44 | marginBottom: 4, 45 | }, 46 | textoRodape: { 47 | fontSize: 12, 48 | fontWeight: '500', 49 | textAlign: 'center', 50 | width: 60, 51 | height: 16, 52 | }, 53 | }); 54 | 55 | export default function Rodape({ selecionado, navegar }) { 56 | return ( 57 | 58 | {/* Home */} 59 | navegar('home')} 62 | > 63 | 64 | 72 | 83 | Home 84 | 85 | 86 | 87 | 88 | {/* Perfil */} 89 | navegar('perfil')} 92 | > 93 | 94 | 104 | 115 | Perfil 116 | 117 | 118 | 119 | 120 | ); 121 | } 122 | -------------------------------------------------------------------------------- /app/estilos/estilosPerfil.js: -------------------------------------------------------------------------------- 1 | import { StyleSheet } from 'react-native'; 2 | 3 | const CORES = { 4 | // Cores de fundo 5 | fundoPrincipal: '#1A1A1D', // Fundo principal do app 6 | corPrimaria: '#D84040', // Cor primária vermelha 7 | 8 | // Cores de texto 9 | textoPrincipal: 'white', // Texto principal branco 10 | textoSecundario: '#999', // Texto secundário cinza 11 | 12 | // Cores de cartões e bordas 13 | fundoCartao: 'rgba(255, 255, 255, 0.05)', // Fundo semi-transparente 14 | bordaClara: 'rgba(255, 255, 255, 0.1)', // Borda clara 15 | bordaBranca: 'white', // Borda branca 16 | 17 | // Cores de interface 18 | cinzaEscuro: '#333', // Cinza escuro para elementos 19 | cinzaEscuroAlt: '#222', // Cinza escuro alternativo 20 | fundoInput: '#333', // Fundo para campos de entrada 21 | bordaInput: '#444', // Borda para campos de entrada 22 | 23 | // Cores de status 24 | fundoSucesso: 'rgba(39, 174, 96, 0.2)', // Verde com opacidade 25 | fundoErro: 'rgba(231, 76, 60, 0.2)', // Vermelho com opacidade 26 | fundoErroEscuro: 'rgba(231, 76, 60, 0.8)', // Vermelho mais escuro 27 | 28 | // Cores de modal 29 | fundoModal: 'rgba(0, 0, 0, 0.7)', // Fundo do modal 30 | }; 31 | 32 | const estilos = StyleSheet.create({ 33 | container: { 34 | flex: 1, 35 | backgroundColor: CORES.fundoPrincipal, 36 | }, 37 | conteudoPrincipal: { 38 | flex: 1, 39 | paddingHorizontal: 16, 40 | }, 41 | secaoReclamacoes: { 42 | backgroundColor: CORES.fundoCartao, 43 | borderRadius: 12, 44 | padding: 16, 45 | marginBottom: 80, 46 | }, 47 | tituloSecao: { 48 | fontSize: 20, 49 | fontWeight: 'bold', 50 | color: CORES.textoPrincipal, 51 | marginBottom: 16, 52 | }, 53 | itemReclamacao: { 54 | flexDirection: 'row', 55 | justifyContent: 'space-between', 56 | alignItems: 'center', 57 | paddingVertical: 12, 58 | borderBottomWidth: 1, 59 | borderBottomColor: CORES.bordaClara, 60 | }, 61 | infoReclamacao: { 62 | flex: 1, 63 | }, 64 | tituloReclamacao: { 65 | fontSize: 16, 66 | color: CORES.textoPrincipal, 67 | fontWeight: '500', 68 | }, 69 | empresaReclamacao: { 70 | fontSize: 14, 71 | color: CORES.corPrimaria, 72 | marginTop: 2, 73 | }, 74 | dataReclamacao: { 75 | fontSize: 12, 76 | color: CORES.textoSecundario, 77 | marginTop: 4, 78 | }, 79 | statusReclamacao: { 80 | paddingHorizontal: 12, 81 | paddingVertical: 6, 82 | borderRadius: 20, 83 | minWidth: 90, 84 | alignItems: 'center', 85 | }, 86 | respondida: { 87 | backgroundColor: CORES.fundoSucesso, 88 | }, 89 | naoRespondida: { 90 | backgroundColor: CORES.fundoErro, 91 | }, 92 | textoStatus: { 93 | fontSize: 12, 94 | fontWeight: 'bold', 95 | color: CORES.textoPrincipal, 96 | }, 97 | semReclamacoes: { 98 | color: CORES.textoSecundario, 99 | textAlign: 'center', 100 | marginVertical: 20, 101 | }, 102 | centrarModal: { 103 | flex: 1, 104 | justifyContent: 'center', 105 | alignItems: 'center', 106 | backgroundColor: CORES.fundoModal, 107 | }, 108 | conteudoModal: { 109 | width: '90%', 110 | maxHeight: '80%', 111 | backgroundColor: CORES.cinzaEscuroAlt, 112 | borderRadius: 12, 113 | overflow: 'hidden', 114 | }, 115 | cabecalhoModal: { 116 | flexDirection: 'row', 117 | justifyContent: 'space-between', 118 | alignItems: 'center', 119 | backgroundColor: CORES.corPrimaria, 120 | paddingVertical: 15, 121 | paddingHorizontal: 20, 122 | }, 123 | tituloModal: { 124 | color: CORES.textoPrincipal, 125 | fontSize: 18, 126 | marginLeft: 26, 127 | }, 128 | botaoFechar: { 129 | padding: 5, 130 | }, 131 | formulario: { 132 | padding: 20, 133 | }, 134 | campoFormulario: { 135 | marginBottom: 16, 136 | }, 137 | labelFormulario: { 138 | color: CORES.textoPrincipal, 139 | fontSize: 14, 140 | marginBottom: 6, 141 | }, 142 | inputFormulario: { 143 | backgroundColor: CORES.fundoInput, 144 | borderRadius: 8, 145 | padding: 12, 146 | color: CORES.textoPrincipal, 147 | borderWidth: 1, 148 | borderColor: CORES.bordaInput, 149 | }, 150 | inputMultiline: { 151 | minHeight: 100, 152 | }, 153 | botaoUploadImagem: { 154 | flexDirection: 'row', 155 | alignItems: 'center', 156 | justifyContent: 'center', 157 | backgroundColor: CORES.fundoInput, 158 | borderRadius: 8, 159 | borderWidth: 1, 160 | borderColor: CORES.bordaInput, 161 | borderStyle: 'dashed', 162 | padding: 20, 163 | }, 164 | textoUploadImagem: { 165 | color: CORES.corPrimaria, 166 | marginLeft: 10, 167 | fontSize: 16, 168 | }, 169 | previewContainer: { 170 | position: 'relative', 171 | marginBottom: 10, 172 | }, 173 | previewImagem: { 174 | width: '100%', 175 | height: 200, 176 | borderRadius: 8, 177 | resizeMode: 'cover', 178 | }, 179 | botaoRemoverImagem: { 180 | position: 'absolute', 181 | top: 10, 182 | right: 10, 183 | backgroundColor: CORES.fundoErroEscuro, 184 | width: 36, 185 | height: 36, 186 | borderRadius: 18, 187 | alignItems: 'center', 188 | justifyContent: 'center', 189 | }, 190 | sucessoContainer: { 191 | padding: 30, 192 | alignItems: 'center', 193 | justifyContent: 'center', 194 | }, 195 | textoSucesso: { 196 | color: CORES.textoPrincipal, 197 | fontSize: 18, 198 | textAlign: 'center', 199 | marginTop: 20, 200 | }, 201 | }); 202 | 203 | export default estilos; 204 | -------------------------------------------------------------------------------- /app/src/api/reclamacao.js: -------------------------------------------------------------------------------- 1 | import api from './axios'; 2 | import AsyncStorage from '@react-native-async-storage/async-storage'; 3 | 4 | export const criarReclamacao = async (dados, token) => { 5 | const response = await api.post('/reclamacoes', dados, { 6 | headers: { 7 | Authorization: `Bearer ${token}`, 8 | }, 9 | }); 10 | return response.data; 11 | }; 12 | 13 | // Busca todas as reclamações do usuário autenticado 14 | export async function buscarMinhasReclamacoes() { 15 | // Recupera o token JWT salvo após o login 16 | const token = await AsyncStorage.getItem('token'); 17 | // Faz a requisição enviando o token no header Authorization 18 | const response = await api.get('/reclamacoes/meu-perfil', { 19 | headers: { 20 | Authorization: `Bearer ${token}`, 21 | }, 22 | }); 23 | return response.data; 24 | } 25 | 26 | // Busca todas as reclamações recebidas pela empresa autenticada 27 | export async function buscarReclamacoesRecebidas() { 28 | // Recupera o token JWT salvo após o login 29 | const token = await AsyncStorage.getItem('token'); 30 | // Faz a requisição enviando o token no header Authorization 31 | const response = await api.get('/reclamacoes/empresa', { 32 | headers: { 33 | Authorization: `Bearer ${token}`, 34 | }, 35 | }); 36 | return response.data; 37 | } 38 | 39 | // Envia resposta para uma reclamação (empresa) 40 | export async function responderReclamacao(id, texto) { 41 | const token = await AsyncStorage.getItem('token'); 42 | const response = await api.patch( 43 | `/reclamacoes/${id}/responder`, 44 | { texto }, 45 | { 46 | headers: { 47 | Authorization: `Bearer ${token}`, 48 | }, 49 | } 50 | ); 51 | return response.data; 52 | } 53 | 54 | export const removerResposta = async (id) => { 55 | try { 56 | const token = await AsyncStorage.getItem('token'); 57 | const response = await api.delete(`/reclamacoes/${id}/remover-resposta`, { 58 | headers: { 59 | Authorization: `Bearer ${token}`, 60 | }, 61 | }); 62 | return response.data; 63 | } catch (error) { 64 | console.error('Erro ao remover resposta da reclamação:', error); 65 | throw error; 66 | } 67 | }; 68 | 69 | export const avaliarReclamacao = async (id, avaliacaoData) => { 70 | try { 71 | /** 72 | * VALIDAÇÃO LOCAL: Dados obrigatórios 73 | * Verifica dados essenciais antes de enviar ao backend 74 | */ 75 | if ( 76 | !avaliacaoData.estrelas || 77 | typeof avaliacaoData.problemaResolvido !== 'boolean' 78 | ) { 79 | throw new Error( 80 | 'Estrelas (1-5) e status do problema (resolvido/não resolvido) são obrigatórios' 81 | ); 82 | } 83 | 84 | /** 85 | * VALIDAÇÃO LOCAL: Range de estrelas 86 | * Garante que estrelas estão no intervalo correto 87 | */ 88 | if ( 89 | !Number.isInteger(avaliacaoData.estrelas) || 90 | avaliacaoData.estrelas < 1 || 91 | avaliacaoData.estrelas > 5 92 | ) { 93 | throw new Error( 94 | 'A avaliação deve ser um número inteiro entre 1 e 5 estrelas' 95 | ); 96 | } 97 | 98 | /** 99 | * PREPARAÇÃO: Estrutura dos dados 100 | * Organiza dados conforme esperado pelo backend 101 | */ 102 | const dadosAvaliacao = { 103 | estrelas: avaliacaoData.estrelas, 104 | problemaResolvido: avaliacaoData.problemaResolvido, 105 | }; 106 | 107 | // Adiciona comentário apenas se foi fornecido e não está vazio 108 | if ( 109 | avaliacaoData.comentario && 110 | avaliacaoData.comentario.trim().length > 0 111 | ) { 112 | dadosAvaliacao.comentario = avaliacaoData.comentario.trim(); 113 | } 114 | 115 | /** 116 | * AUTENTICAÇÃO: Token de usuário 117 | * Obtém token do AsyncStorage para autorização 118 | */ 119 | const token = await AsyncStorage.getItem('token'); 120 | if (!token) { 121 | throw new Error( 122 | 'Token de autenticação não encontrado. Faça login novamente.' 123 | ); 124 | } 125 | 126 | /** 127 | * REQUISIÇÃO: Envio da avaliação 128 | * POST para endpoint de avaliação com dados e autorização 129 | */ 130 | const response = await api.post( 131 | `/reclamacoes/${id}/avaliar`, 132 | dadosAvaliacao, 133 | { 134 | headers: { 135 | Authorization: `Bearer ${token}`, 136 | 'Content-Type': 'application/json', 137 | }, 138 | } 139 | ); 140 | 141 | /** 142 | * RESPOSTA DE SUCESSO 143 | * Retorna dados da reclamação atualizada 144 | */ 145 | console.log(' Avaliação registrada:', response.data.avaliacaoDetalhes); 146 | return response.data; 147 | } catch (error) { 148 | /** 149 | * TRATAMENTO DE ERROS 150 | * Logs detalhados e propagação do erro para o componente 151 | */ 152 | console.error(' Erro ao avaliar reclamação:', { 153 | reclamacaoId: id, 154 | dadosEnviados: avaliacaoData, 155 | erro: error?.response?.data || error.message, 156 | }); 157 | 158 | // Re-throw com mensagem mais amigável se disponível 159 | const mensagemErro = 160 | error?.response?.data?.msg || 161 | error.message || 162 | 'Erro ao registrar avaliação'; 163 | throw new Error(mensagemErro); 164 | } 165 | }; 166 | 167 | export const deletarReclamacao = async (id) => { 168 | try { 169 | const token = await AsyncStorage.getItem('token'); 170 | 171 | if (!token) { 172 | throw new Error('Token de autenticação não encontrado'); 173 | } 174 | 175 | const response = await api.delete(`/reclamacoes/${id}`, { 176 | headers: { 177 | Authorization: `Bearer ${token}`, 178 | }, 179 | }); 180 | 181 | console.log('Reclamação deletada com sucesso:', id); 182 | return response.data; 183 | } catch (error) { 184 | console.log(' Erro ao deletar reclamação', error); 185 | 186 | const mensagemErro = 187 | error?.response?.data?.msg || 188 | error.message || 189 | 'Erro ao deletar reclamação'; 190 | throw new Error(mensagemErro); 191 | } 192 | }; 193 | -------------------------------------------------------------------------------- /backend/src/models/Reclamacao.js: -------------------------------------------------------------------------------- 1 | import mongoose from 'mongoose'; 2 | 3 | const reclamacaoSchema = new mongoose.Schema( 4 | { 5 | titulo: { 6 | type: String, 7 | required: [true, 'O título da reclamação é obrigatório.'], 8 | trim: true, 9 | }, 10 | 11 | descricao: { 12 | type: String, 13 | required: [true, 'A descrição da reclamação é obrigatória.'], 14 | trim: true, 15 | }, 16 | 17 | user: { 18 | type: mongoose.Schema.Types.ObjectId, 19 | ref: 'User', 20 | required: [true, 'O autor da reclamação é obrigatório.'], 21 | }, 22 | 23 | empresa: { 24 | type: mongoose.Schema.Types.ObjectId, 25 | ref: 'Empresa', 26 | required: [true, 'A empresa alvo da reclamação é obrigatória.'], 27 | }, 28 | 29 | /** 30 | * Campo de contato para comunicação direta entre empresa e cliente 31 | * Aceita email ou WhatsApp seguindo padrões internacionais 32 | * Implementa validação dual para maior flexibilidade 33 | */ 34 | contato: { 35 | type: String, 36 | required: [true, 'O contato (email ou WhatsApp) é obrigatório.'], 37 | trim: true, 38 | maxlength: [100, 'Contato deve ter no máximo 100 caracteres.'], 39 | validate: { 40 | validator: function (valor) { 41 | // Não permite campo vazio se é obrigatório 42 | if (!valor || !valor.trim()) return false; 43 | 44 | // Regex para validação de email (RFC 5322 simplificado) 45 | const emailRegex = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/; 46 | 47 | // Regex para validação de WhatsApp (formato internacional e nacional) 48 | // Aceita: +5511999999999, 11999999999, (11)99999-9999 49 | const whatsappRegex = 50 | /^(?:\+?55\s?)?(?:\(?\d{2}\)?[\s-]?)(?:9\d{4}[\s-]?\d{4}|\d{4}[\s-]?\d{4})$/; 51 | 52 | return emailRegex.test(valor) || whatsappRegex.test(valor); 53 | }, 54 | message: 55 | 'Contato deve ser um email válido ou WhatsApp no formato correto.', 56 | }, 57 | }, 58 | 59 | imagem: { 60 | data: Buffer, // conteúdo da imagem 61 | contentType: String, // ex: 'image/jpeg', 'image/png' 62 | }, 63 | 64 | resposta: { 65 | texto: { 66 | type: String, 67 | trim: true, 68 | }, 69 | respondidoPor: { 70 | type: mongoose.Schema.Types.ObjectId, 71 | ref: 'Empresa', 72 | }, 73 | data: { 74 | type: Date, 75 | }, 76 | }, 77 | 78 | status: { 79 | type: String, 80 | enum: ['aberta', 'respondida', 'fechada'], 81 | default: 'aberta', 82 | }, 83 | 84 | /** 85 | * Sistema de avaliação da resposta da empresa 86 | * Permite aos clientes avaliar a qualidade do atendimento recebido 87 | * Só pode ser preenchido após a empresa responder à reclamação 88 | */ 89 | avaliacao: { 90 | /** 91 | * Classificação por estrelas (1-5) 92 | * Representa a satisfação geral com a resposta 93 | */ 94 | estrelas: { 95 | type: Number, 96 | min: [1, 'A avaliação deve ter pelo menos 1 estrela.'], 97 | max: [5, 'A avaliação deve ter no máximo 5 estrelas.'], 98 | validate: { 99 | validator: function (valor) { 100 | // Valida se é um número inteiro 101 | return Number.isInteger(valor); 102 | }, 103 | message: 'A avaliação deve ser um número inteiro entre 1 e 5.', 104 | }, 105 | }, 106 | 107 | /** 108 | * Indicador se o problema foi resolvido 109 | * Métrica importante para avaliar eficácia da resolução 110 | */ 111 | problemaResolvido: { 112 | type: Boolean, 113 | required: function () { 114 | // Obrigatório apenas se há avaliação por estrelas 115 | return this.avaliacao && this.avaliacao.estrelas; 116 | }, 117 | }, 118 | 119 | /** 120 | * Comentário opcional sobre a resposta 121 | * Feedback qualitativo complementar à avaliação quantitativa 122 | */ 123 | comentario: { 124 | type: String, 125 | trim: true, 126 | maxlength: [500, 'O comentário deve ter no máximo 500 caracteres.'], 127 | validate: { 128 | validator: function (valor) { 129 | // Se preenchido, deve ter pelo menos 10 caracteres 130 | if (valor && valor.trim().length > 0) { 131 | return valor.trim().length >= 10; 132 | } 133 | return true; // Campo opcional 134 | }, 135 | message: 136 | 'O comentário deve ter pelo menos 10 caracteres se preenchido.', 137 | }, 138 | }, 139 | 140 | /** 141 | * Timestamp da avaliação 142 | * Importante para análises temporais e auditoria 143 | */ 144 | dataAvaliacao: { 145 | type: Date, 146 | default: function () { 147 | // Só define data se há estrelas (indica que foi avaliado) 148 | return this.avaliacao && this.avaliacao.estrelas 149 | ? Date.now() 150 | : undefined; 151 | }, 152 | }, 153 | }, 154 | }, 155 | { 156 | timestamps: true, // cria createdAt e updatedAt automaticamente 157 | } 158 | ); 159 | 160 | /** 161 | * Middleware pré-save para sanitização adicional do campo contato 162 | * Garante consistência dos dados antes da persistência 163 | */ 164 | reclamacaoSchema.pre('save', function (next) { 165 | if (this.contato) { 166 | // Remove espaços extras e caracteres especiais desnecessários 167 | this.contato = this.contato.trim().replace(/\s+/g, ' '); 168 | 169 | // Normaliza WhatsApp para formato padrão (+55XXXXXXXXXXX) 170 | if (!/^[a-zA-Z0-9._%+-]+@/.test(this.contato)) { 171 | // Remove todos os caracteres não numéricos 172 | const numeros = this.contato.replace(/\D/g, ''); 173 | 174 | // Adiciona código do país se não existir 175 | if (numeros.length === 11 && numeros.startsWith('1')) { 176 | this.contato = `+55${numeros}`; 177 | } else if (numeros.length === 10) { 178 | this.contato = `+55${numeros}`; 179 | } 180 | } 181 | } 182 | next(); 183 | }); 184 | 185 | const Reclamacao = mongoose.model('Reclamacao', reclamacaoSchema); 186 | 187 | export default Reclamacao; 188 | -------------------------------------------------------------------------------- /app/dashboard.jsx: -------------------------------------------------------------------------------- 1 | import { useState } from 'react'; 2 | import estilos from './estilos/estilosPerfilEmpresa'; 3 | import { 4 | View, 5 | Text, 6 | Pressable, 7 | FlatList, 8 | ActivityIndicator, 9 | } from 'react-native'; 10 | import { FontAwesome } from '@expo/vector-icons'; 11 | import ModalRespostaReclamacao from './components/ModalRespostaReclamacao'; 12 | import { responderReclamacao } from './src/api/reclamacao'; 13 | import { useReclamacoesRecebidas } from './src/hooks/useReclamacoesRecebidas'; 14 | import LogoutButton from './components/LogoutButton'; 15 | 16 | export default function PerfilEmpresaScreen() { 17 | // Usa o hook customizado para buscar reclamações recebidas 18 | const { 19 | reclamacoes, 20 | carregando: carregandoReclamacoes, 21 | refresh, 22 | } = useReclamacoesRecebidas(); 23 | const [modalVisivel, setModalVisivel] = useState(false); 24 | const [reclamacaoSelecionada, setReclamacaoSelecionada] = useState(null); 25 | const [respostaReclamacao, setRespostaReclamacao] = useState(''); 26 | 27 | // Estado de loading para envio de resposta 28 | const [enviandoResposta, setEnviandoResposta] = useState(false); 29 | 30 | // Função para abrir o modal de resposta 31 | const abrirModalResposta = (reclamacao) => { 32 | // Verifica se a reclamação já foi respondida 33 | if (reclamacao.status !== 'aberta') { 34 | return; // Não abre o modal se já foi respondida 35 | } 36 | 37 | setReclamacaoSelecionada(reclamacao); 38 | setRespostaReclamacao(''); 39 | setModalVisivel(true); 40 | }; 41 | 42 | // ✅ MELHORADO: Função para enviar resposta com loading 43 | const enviarResposta = async () => { 44 | if (!respostaReclamacao.trim()) { 45 | alert('Por favor, digite uma resposta para a reclamação.'); 46 | return; 47 | } 48 | 49 | try { 50 | setEnviandoResposta(true); // ✅ Inicia loading 51 | 52 | await responderReclamacao(reclamacaoSelecionada._id, respostaReclamacao); 53 | await refresh(); // Atualiza a lista após responder 54 | 55 | // ✅ Feedback de sucesso 56 | alert('✅ Resposta enviada com sucesso!'); 57 | 58 | setModalVisivel(false); 59 | setReclamacaoSelecionada(null); 60 | setRespostaReclamacao(''); 61 | } catch (erro) { 62 | alert( 63 | erro?.response?.data?.msg || 'Erro ao enviar resposta. Tente novamente.' 64 | ); 65 | console.error('Erro ao responder reclamação:', erro); 66 | } finally { 67 | setEnviandoResposta(false); // ✅ Finaliza loading 68 | } 69 | }; 70 | 71 | // Renderiza cada item da lista de reclamações 72 | const renderReclamacao = ({ item }) => { 73 | const jaRespondida = item.status !== 'aberta'; 74 | 75 | return ( 76 | !jaRespondida && abrirModalResposta(item)} 85 | disabled={jaRespondida} 86 | activeOpacity={jaRespondida ? 1 : 0.7} 87 | > 88 | 89 | 90 | {item.user?.nome || 'Nome não informado'} 91 | 92 | 93 | {item.createdAt 94 | ? new Date(item.createdAt).toLocaleDateString('pt-BR', { 95 | day: '2-digit', 96 | month: '2-digit', 97 | year: 'numeric', 98 | }) 99 | : 'Data não informada'} 100 | 101 | 102 | 103 | {item.titulo} 104 | 108 | {item.descricao} 109 | 110 | 111 | 112 | 118 | {item.status === 'aberta' ? 'Pendente' : 'Respondida'} 119 | 120 | 121 | 126 | 127 | 128 | ); 129 | }; 130 | 131 | return ( 132 | 133 | {/* Cabeçalho com nome da plataforma */} 134 | 145 | 146 | DESATENDE 147 | Painel Administrativo 148 | 149 | 150 | 151 | 152 | {/* Seção de perfil da empresa */} 153 | 154 | {/* Painel administrativo de reclamações */} 155 | 156 | Reclamações Recebidas 157 | 158 | {carregandoReclamacoes ? ( 159 | 160 | 164 | 165 | Carregando reclamações... 166 | 167 | 168 | ) : reclamacoes.length === 0 ? ( 169 | 170 | 175 | 176 | Nenhuma reclamação encontrada! 177 | 178 | 179 | ) : ( 180 | item._id} 184 | contentContainerStyle={estilos.reclamacoesListContainer} 185 | showsVerticalScrollIndicator={false} 186 | /> 187 | )} 188 | 189 | 190 | {/* Modal para responder reclamação */} 191 | setModalVisivel(false)} 194 | reclamacao={reclamacaoSelecionada} 195 | resposta={respostaReclamacao} 196 | setResposta={setRespostaReclamacao} 197 | onEnviar={enviarResposta} 198 | enviandoResposta={enviandoResposta} // Passa estado de loading 199 | /> 200 | 201 | {/* Falso Footer Minimalista */} 202 | 203 | Painel Administrativo 204 | 205 | 206 | ); 207 | } 208 | -------------------------------------------------------------------------------- /backend/tests/auth.test.js: -------------------------------------------------------------------------------- 1 | import { assert, test, describe } from 'poku'; 2 | import bcrypt from 'bcrypt'; 3 | import jwt from 'jsonwebtoken'; 4 | 5 | // Simulando as funções que normalmente estariam nos modelos 6 | const hashPassword = async (password) => { 7 | const salt = await bcrypt.genSalt(10); 8 | return await bcrypt.hash(password, salt); 9 | }; 10 | 11 | const comparePassword = async (password, hashedPassword) => { 12 | return await bcrypt.compare(password, hashedPassword); 13 | }; 14 | 15 | const generateToken = (userId, tipo = 'user') => { 16 | return jwt.sign( 17 | { id: userId, tipo }, 18 | process.env.JWT_SECRET || 'test-secret', 19 | { expiresIn: '7d' } 20 | ); 21 | }; 22 | 23 | const verifyToken = (token) => { 24 | return jwt.verify(token, process.env.JWT_SECRET || 'test-secret'); 25 | }; 26 | 27 | describe('Testes Unitários de Autenticação', () => { 28 | describe('Hash de Senhas', () => { 29 | test('Deve gerar hash da senha corretamente', async () => { 30 | const senha = '123456'; 31 | const hashedPassword = await hashPassword(senha); 32 | 33 | assert(hashedPassword, 'Hash deve ser gerado'); 34 | assert( 35 | hashedPassword !== senha, 36 | 'Hash deve ser diferente da senha original' 37 | ); 38 | assert(hashedPassword.length > 50, 'Hash deve ter tamanho adequado'); 39 | }); 40 | 41 | test('Deve verificar senha correta', async () => { 42 | const senha = '123456'; 43 | const hashedPassword = await hashPassword(senha); 44 | const isValid = await comparePassword(senha, hashedPassword); 45 | 46 | assert(isValid === true, 'Senha correta deve ser validada'); 47 | }); 48 | 49 | test('Deve rejeitar senha incorreta', async () => { 50 | const senhaCorreta = '123456'; 51 | const senhaIncorreta = '654321'; 52 | const hashedPassword = await hashPassword(senhaCorreta); 53 | const isValid = await comparePassword(senhaIncorreta, hashedPassword); 54 | 55 | assert(isValid === false, 'Senha incorreta deve ser rejeitada'); 56 | }); 57 | }); 58 | 59 | describe('Tokens JWT', () => { 60 | test('Deve gerar token válido para usuário', async () => { 61 | const userId = '507f1f77bcf86cd799439011'; 62 | const token = generateToken(userId, 'user'); 63 | 64 | assert(token, 'Token deve ser gerado'); 65 | assert(typeof token === 'string', 'Token deve ser uma string'); 66 | assert( 67 | token.split('.').length === 3, 68 | 'Token deve ter formato JWT válido' 69 | ); 70 | }); 71 | 72 | test('Deve gerar token válido para empresa', async () => { 73 | const empresaId = '507f1f77bcf86cd799439012'; 74 | const token = generateToken(empresaId, 'empresa'); 75 | 76 | assert(token, 'Token deve ser gerado'); 77 | assert(typeof token === 'string', 'Token deve ser uma string'); 78 | }); 79 | 80 | test('Deve verificar token válido', async () => { 81 | const userId = '507f1f77bcf86cd799439011'; 82 | const token = generateToken(userId, 'user'); 83 | const decoded = verifyToken(token); 84 | 85 | assert(decoded, 'Token deve ser decodificado'); 86 | assert(decoded.id === userId, 'ID do usuário deve coincidir'); 87 | assert(decoded.tipo === 'user', 'Tipo deve coincidir'); 88 | }); 89 | 90 | test('Deve rejeitar token inválido', async () => { 91 | const tokenInvalido = 'token.invalido.aqui'; 92 | 93 | try { 94 | verifyToken(tokenInvalido); 95 | assert(false, 'Token inválido deveria lançar erro'); 96 | } catch (error) { 97 | assert(error.name === 'JsonWebTokenError', 'Deve lançar erro de JWT'); 98 | } 99 | }); 100 | }); 101 | 102 | describe('Validação de Email', () => { 103 | const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; 104 | 105 | test('Deve aceitar emails válidos', async () => { 106 | const emailsValidos = [ 107 | 'usuario@teste.com', 108 | 'empresa@dominio.com.br', 109 | 'test.user@example.org', 110 | 'user+tag@gmail.com', 111 | ]; 112 | 113 | emailsValidos.forEach((email) => { 114 | const isValid = emailRegex.test(email); 115 | assert(isValid, `Email ${email} deve ser válido`); 116 | }); 117 | }); 118 | 119 | test('Deve rejeitar emails inválidos', async () => { 120 | const emailsInvalidos = [ 121 | 'email-sem-arroba', 122 | 'usuario@', 123 | '@dominio.com', 124 | 'usuario@dominio', 125 | 'usuario @teste.com', 126 | ]; 127 | 128 | emailsInvalidos.forEach((email) => { 129 | const isValid = emailRegex.test(email); 130 | assert(!isValid, `Email ${email} deve ser inválido`); 131 | }); 132 | }); 133 | }); 134 | 135 | describe('Validação de Nomes', () => { 136 | test('Deve aceitar nomes de usuários válidos', async () => { 137 | const nomeRegex = /^[a-zA-ZÀ-ÿ\s]+$/; 138 | const nomesValidos = [ 139 | 'João Silva', 140 | 'Maria José', 141 | 'Pedro', 142 | 'Ana Paula de Souza', 143 | 'José da Silva', 144 | ]; 145 | 146 | nomesValidos.forEach((nome) => { 147 | const isValid = nomeRegex.test(nome) && nome.trim().length >= 2; 148 | assert(isValid, `Nome "${nome}" deve ser válido para usuário`); 149 | }); 150 | }); 151 | 152 | test('Deve rejeitar nomes de usuários inválidos', async () => { 153 | const nomeRegex = /^[a-zA-ZÀ-ÿ\s]+$/; 154 | const nomesInvalidos = [ 155 | 'João123', 156 | 'Maria@Silva', 157 | 'Pedro_Santos', 158 | 'Ana#Paula', 159 | 'J', 160 | '', 161 | ]; 162 | 163 | nomesInvalidos.forEach((nome) => { 164 | const isValid = nomeRegex.test(nome) && nome.trim().length >= 2; 165 | assert(!isValid, `Nome "${nome}" deve ser inválido para usuário`); 166 | }); 167 | }); 168 | 169 | test('Deve aceitar nomes de empresas válidos', async () => { 170 | const empresaRegex = /^[a-zA-ZÀ-ÿ0-9\s\-\.]+$/; 171 | const nomesValidos = [ 172 | 'Empresa XYZ Ltda.', 173 | 'Tech Solutions 123', 174 | 'Consultoria ABC', 175 | 'Loja do João', 176 | 'Auto Peças Silva e Silva', 177 | ]; 178 | 179 | nomesValidos.forEach((nome) => { 180 | const isValid = empresaRegex.test(nome) && nome.trim().length >= 2; 181 | assert(isValid, `Nome "${nome}" deve ser válido para empresa`); 182 | }); 183 | }); 184 | }); 185 | 186 | describe('Validação de Senhas', () => { 187 | test('Deve aceitar senhas válidas', async () => { 188 | const senhasValidas = [ 189 | '123456', 190 | 'minhasenha', 191 | 'senha123', 192 | 'SenhaComMaiuscula', 193 | 'senha-com-hifen', 194 | ]; 195 | 196 | senhasValidas.forEach((senha) => { 197 | const isValid = senha.length >= 6; 198 | assert(isValid, `Senha "${senha}" deve ser válida`); 199 | }); 200 | }); 201 | 202 | test('Deve rejeitar senhas muito curtas', async () => { 203 | const senhasInvalidas = ['123', '12345', 'abc', 'aa', '']; 204 | 205 | senhasInvalidas.forEach((senha) => { 206 | const isValid = senha.length >= 6; 207 | assert(!isValid, `Senha "${senha}" deve ser inválida`); 208 | }); 209 | }); 210 | }); 211 | }); 212 | -------------------------------------------------------------------------------- /app/estilos/estilosPerfilEmpresa.js: -------------------------------------------------------------------------------- 1 | import { StyleSheet } from 'react-native'; 2 | 3 | // PALETA DRACULA COMPLETA 4 | // Cores oficiais do tema Dracula para uma interface dark consistente 5 | // https://draculatheme.com/contribute 6 | 7 | const COLORS = { 8 | // ========== CORES BASES DRACULA ========== 9 | 10 | // CORES PRINCIPAIS DRACULA 11 | background: '#282a36', // Fundo principal 12 | currentLine: '#44475a', // Linha atual/hover 13 | foreground: '#f8f8f2', // Texto principal 14 | comment: '#6272a4', // Comentários 15 | selection: '#44475a', // Seleção 16 | 17 | // CORES DE TEXTO DRACULA 18 | cyan: '#8be9fd', // Azul claro 19 | green: '#50fa7b', // Verde 20 | orange: '#ffb86c', // Laranja 21 | pink: '#ff79c6', // Rosa 22 | purple: '#bd93f9', // Roxo 23 | red: '#ff5555', // Vermelho 24 | yellow: '#f1fa8c', // Amarelo 25 | 26 | // CORES DE STATUS DRACULA 27 | success: '#50fa7b', // Verde para sucesso 28 | warning: '#ffb86c', // Laranja para avisos 29 | error: '#ff5555', // Vermelho para erros 30 | info: '#8be9fd', // Azul para informações 31 | 32 | // CORES ESPECIAIS 33 | white: '#f8f8f2', // Branco Dracula 34 | black: '#282a36', // Preto Dracula 35 | }; 36 | 37 | const estilos = StyleSheet.create({ 38 | container: { 39 | flex: 1, 40 | backgroundColor: COLORS.background, 41 | paddingTop: 32, // Reduzido para melhor UX 42 | paddingHorizontal: 12, // Mais compacto 43 | paddingBottom: 70, // Espaço para footer 44 | }, 45 | 46 | /* CABEÇALHO DRACULA UX */ 47 | cabecalho: { 48 | marginBottom: 24, // Aumentado para melhor separação 49 | alignItems: 'center', 50 | paddingVertical: 16, 51 | marginHorizontal: 4, 52 | }, 53 | labelPlataforma: { 54 | fontSize: 28, // Reduzido para melhor proporção 55 | fontWeight: 'bold', 56 | textAlign: 'center', 57 | color: '#D84040', // Mudado para a cor solicitada 58 | letterSpacing: 1.5, 59 | }, 60 | labelPainel: { 61 | fontSize: 16, // Ligeiramente reduzido 62 | textAlign: 'center', 63 | color: COLORS.foreground, 64 | marginTop: 4, // Reduzido 65 | fontWeight: '500', 66 | letterSpacing: 0.5, 67 | }, 68 | 69 | /* PAINEL DE RECLAMAÇÕES UX MELHORADO */ 70 | painelContainer: { 71 | flex: 1, 72 | marginBottom: 8, // Reduzido 73 | }, 74 | painelTitulo: { 75 | fontSize: 18, // Reduzido para melhor hierarquia 76 | fontWeight: 'bold', 77 | textAlign: 'center', 78 | color: COLORS.cyan, // Cyan para diferenciação 79 | marginBottom: 16, // Otimizado 80 | paddingHorizontal: 16, 81 | paddingVertical: 8, 82 | }, 83 | 84 | /* ESTADOS DE CARREGAMENTO DRACULA */ 85 | carregandoContainer: { 86 | flex: 1, 87 | justifyContent: 'center', 88 | alignItems: 'center', 89 | backgroundColor: COLORS.selection, 90 | borderRadius: 16, 91 | marginHorizontal: 8, 92 | paddingVertical: 40, 93 | }, 94 | carregandoTexto: { 95 | marginTop: 16, 96 | color: COLORS.foreground, 97 | fontSize: 16, 98 | fontWeight: '500', 99 | }, 100 | semDadosContainer: { 101 | flex: 1, 102 | justifyContent: 'center', 103 | alignItems: 'center', 104 | backgroundColor: COLORS.selection, 105 | borderRadius: 16, 106 | marginHorizontal: 8, 107 | paddingVertical: 40, 108 | }, 109 | semDadosTexto: { 110 | marginTop: 16, 111 | color: COLORS.foreground, 112 | fontSize: 16, 113 | fontWeight: '500', 114 | textAlign: 'center', 115 | }, 116 | 117 | /* LISTA DE RECLAMAÇÕES UX */ 118 | reclamacoesListContainer: { 119 | paddingBottom: 20, // Reduzido 120 | paddingHorizontal: 4, // Pequeno padding lateral 121 | }, 122 | 123 | /* ITEM DE RECLAMAÇÃO DRACULA */ 124 | reclamacaoItem: { 125 | backgroundColor: COLORS.selection, 126 | borderRadius: 14, // Mais arredondado 127 | padding: 16, // Padding otimizado 128 | marginBottom: 12, // Espaçamento menor 129 | marginHorizontal: 4, // Pequena margem lateral 130 | borderLeftWidth: 5, // Borda mais espessa 131 | shadowColor: '#000', 132 | shadowOpacity: 0.25, 133 | shadowRadius: 8, 134 | elevation: 6, 135 | borderTopRightRadius: 14, 136 | borderBottomRightRadius: 14, 137 | }, 138 | reclamacaoHeader: { 139 | flexDirection: 'row', 140 | justifyContent: 'space-between', 141 | marginBottom: 8, // Aumentado 142 | alignItems: 'center', 143 | }, 144 | reclamacaoUsuario: { 145 | fontSize: 15, // Ligeiramente aumentado 146 | fontWeight: 'bold', 147 | color: COLORS.foreground, 148 | flex: 1, 149 | }, 150 | reclamacaoData: { 151 | fontSize: 12, 152 | color: COLORS.comment, 153 | fontWeight: '500', 154 | backgroundColor: COLORS.comment, 155 | paddingHorizontal: 8, 156 | paddingVertical: 4, 157 | borderRadius: 8, 158 | }, 159 | reclamacaoTitulo: { 160 | fontSize: 16, 161 | fontWeight: 'bold', 162 | color: COLORS.cyan, // Cyan para títulos 163 | marginBottom: 8, // Aumentado 164 | lineHeight: 22, 165 | }, 166 | reclamacaoDescricao: { 167 | fontSize: 14, 168 | color: COLORS.currentLine, 169 | marginBottom: 12, // Aumentado 170 | lineHeight: 20, 171 | }, 172 | reclamacaoFooter: { 173 | flexDirection: 'row', 174 | justifyContent: 'space-between', 175 | alignItems: 'center', 176 | paddingTop: 8, // Separação visual 177 | borderTopWidth: 1, 178 | borderTopColor: COLORS.comment, 179 | }, 180 | reclamacaoStatus: { 181 | fontSize: 13, // Ligeiramente reduzido 182 | fontWeight: 'bold', 183 | textTransform: 'uppercase', 184 | letterSpacing: 0.5, 185 | }, 186 | 187 | /* PERFIL DA EMPRESA - MANTIDO PARA COMPATIBILIDADE */ 188 | perfilContainer: { 189 | flexDirection: 'row', 190 | alignItems: 'center', 191 | marginBottom: 20, 192 | padding: 15, 193 | backgroundColor: COLORS.selection, 194 | borderRadius: 14, 195 | shadowColor: '#000', 196 | shadowOpacity: 0.2, 197 | shadowRadius: 6, 198 | elevation: 4, 199 | }, 200 | logoContainer: { 201 | position: 'relative', 202 | width: 80, 203 | height: 80, 204 | borderRadius: 40, 205 | overflow: 'hidden', 206 | marginRight: 15, 207 | }, 208 | logoEmpresa: { 209 | width: 80, 210 | height: 80, 211 | borderRadius: 40, 212 | }, 213 | placeholderContainer: { 214 | position: 'absolute', 215 | justifyContent: 'center', 216 | alignItems: 'center', 217 | backgroundColor: COLORS.comment, 218 | width: 80, 219 | height: 80, 220 | borderRadius: 40, 221 | }, 222 | placeholderText: { 223 | fontSize: 32, 224 | fontWeight: 'bold', 225 | color: COLORS.purple, 226 | }, 227 | loadingIndicator: { 228 | position: 'absolute', 229 | bottom: 10, 230 | }, 231 | infoEmpresa: { 232 | flex: 1, 233 | }, 234 | nomeEmpresa: { 235 | fontSize: 20, 236 | fontWeight: 'bold', 237 | color: COLORS.foreground, 238 | marginBottom: 5, 239 | }, 240 | descricaoEmpresa: { 241 | fontSize: 14, 242 | color: COLORS.comment, 243 | }, 244 | 245 | /* FALSO FOOTER MINIMALISTA */ 246 | falsoFooter: { 247 | position: 'absolute', 248 | bottom: 0, 249 | left: 0, 250 | right: 0, 251 | height: 60, 252 | backgroundColor: COLORS.selection, 253 | borderTopWidth: 1, 254 | borderTopColor: COLORS.comment, 255 | justifyContent: 'center', 256 | alignItems: 'center', 257 | shadowColor: '#000', 258 | shadowOpacity: 0.2, 259 | shadowRadius: 6, 260 | elevation: 8, 261 | }, 262 | falsoFooterTexto: { 263 | color: COLORS.comment, 264 | fontSize: 12, 265 | fontWeight: '500', 266 | letterSpacing: 0.5, 267 | }, 268 | }); 269 | 270 | export default estilos; 271 | -------------------------------------------------------------------------------- /app/home.jsx: -------------------------------------------------------------------------------- 1 | // HOME CLIENTE!! 2 | import { useState } from 'react'; 3 | import estilos from './estilos/estilosHome'; 4 | import { 5 | View, 6 | Text, 7 | TextInput, 8 | FlatList, 9 | ActivityIndicator, 10 | } from 'react-native'; 11 | import { FontAwesome } from '@expo/vector-icons'; 12 | // Importa o hook de navegação do Expo Router 13 | import { useRouter } from 'expo-router'; 14 | import LottieView from 'lottie-react-native'; 15 | import Rodape from './components/Rodape'; 16 | import EmpresaItem from './components/EmpresaItem'; 17 | import AsyncStorage from '@react-native-async-storage/async-storage'; 18 | import api from './src/api/axios'; 19 | import ModalCriarReclamacao from './components/ModalCriarReclamacao'; 20 | import { useEmpresas } from './src/hooks/useEmpresas'; 21 | import { useFeedback } from './src/hooks/useFeedback'; 22 | import HeaderTitulo from './components/HeaderTitulo'; 23 | import mcdonalds1 from './imgs/mcdonalds1.png'; 24 | import americanScaralines2 from './imgs/americanScaralines2.png'; 25 | import CocoBangu3 from './imgs/CocoBangu3.png'; 26 | import Marvan4 from './imgs/Marvan4.png'; 27 | import Carrefive5 from './imgs/Carrefive5.png'; 28 | 29 | export default function HomeScreen() { 30 | const [search, setSearch] = useState(''); 31 | const [imagensCarregadas, setImagensCarregadas] = useState({}); 32 | const router = useRouter(); 33 | // Busca empresas reais do backend via hook 34 | const { empresas, carregando: carregandoEmpresas } = useEmpresas(); 35 | // Filtra as 8 primeiras empresas como populares 36 | const empresasPopulares = empresas.slice(0, 8); 37 | 38 | // Estados para modal e empresa selecionada 39 | const [modalVisivel, setModalVisivel] = useState(false); 40 | const [empresaSelecionada, setEmpresaSelecionada] = useState(null); 41 | 42 | const feedback = useFeedback(); 43 | 44 | // Imagens temporárias para as 5 primeiras empresas 45 | const imagensTemporarias = [ 46 | mcdonalds1, 47 | americanScaralines2, 48 | CocoBangu3, 49 | Marvan4, 50 | Carrefive5, 51 | ]; 52 | 53 | // Sobrescreve as imagens das 5 primeiras empresas 54 | const empresasComImagens = empresasPopulares.map((empresa, idx) => { 55 | if (idx < 5) { 56 | return { ...empresa, imagem: imagensTemporarias[idx] }; 57 | } 58 | return empresa; 59 | }); 60 | 61 | const handleImageLoad = (id) => { 62 | setImagensCarregadas((prev) => ({ ...prev, [id]: true })); 63 | }; 64 | 65 | // Função para abrir o modal com a empresa selecionada 66 | const abrirModalReclamacao = (empresa) => { 67 | setEmpresaSelecionada(empresa); 68 | setModalVisivel(true); 69 | }; 70 | 71 | // Função para fechar o modal 72 | const fecharModal = () => { 73 | setModalVisivel(false); 74 | setEmpresaSelecionada(null); 75 | }; 76 | 77 | return ( 78 | 79 | 80 | 81 | {/* Seção de Pesquisa */} 82 | 83 | 84 | 90 | 99 | 100 | 101 | 102 | {/* Banner Principal - MOVIDO PARA PRÓXIMO DO INPUT */} 103 | 104 | 110 | 111 | 112 | {/* Texto explicativo - REDUZIDO */} 113 | 114 | Relate experiências ruins e ajude outros consumidores 115 | 116 | 117 | {/* Lista horizontal de empresas */} 118 | 119 | Empresas Populares 120 | {carregandoEmpresas ? ( 121 | 126 | ) : ( 127 | 129 | e.nome.toLowerCase().includes(search.toLowerCase()) 130 | )} 131 | renderItem={({ item }) => ( 132 | handleImageLoad(item._id)} 136 | onPress={() => abrirModalReclamacao(item)} 137 | /> 138 | )} 139 | keyExtractor={(item) => item._id.toString()} 140 | horizontal 141 | showsHorizontalScrollIndicator={false} 142 | contentContainerStyle={estilos.empresasListContainer} 143 | /> 144 | )} 145 | 146 | 147 | {/* Footer */} 148 | { 151 | if (destino === 'home') return; 152 | if (destino === 'perfil') router.push('./perfil'); 153 | }} 154 | /> 155 | 156 | {/* Feedback global */} 157 | {feedback.loading && ( 158 | 161 | Enviando reclamação... 162 | 163 | )} 164 | {feedback.success && ( 165 | 168 | {feedback.success} 169 | 170 | )} 171 | {feedback.error && ( 172 | 175 | {feedback.error} 176 | 177 | )} 178 | 179 | {/* Modal de Reclamação */} 180 | { 188 | try { 189 | feedback.setLoading(true); 190 | const token = await AsyncStorage.getItem('token'); 191 | if (!token) { 192 | feedback.showError( 193 | 'Usuário não autenticado. Faça login novamente.' 194 | ); 195 | return; 196 | } 197 | 198 | /** 199 | * Construção do FormData seguindo padrões REST 200 | * Inclui validação e tratamento do campo contato 201 | */ 202 | const formData = new FormData(); 203 | formData.append('titulo', titulo); 204 | formData.append('descricao', descricao); 205 | formData.append( 206 | 'empresa', 207 | String(empresaId || empresaSelecionada?._id) 208 | ); 209 | 210 | // Adiciona contato apenas se fornecido e válido 211 | if (contato && contato.trim()) { 212 | formData.append('contato', contato.trim()); 213 | } 214 | 215 | /** 216 | * Processamento de imagem mantendo compatibilidade 217 | * com sistema de upload existente 218 | */ 219 | if (imagemUri) { 220 | const filename = imagemUri.split('/').pop(); 221 | const match = filename ? /\.(\w+)$/.exec(filename) : null; 222 | const type = match ? `image/${match[1]}` : 'image'; 223 | formData.append('imagem', { 224 | uri: imagemUri, 225 | name: filename ?? 'foto.jpg', 226 | type, 227 | }); 228 | } 229 | 230 | // Requisição para API seguindo padrões RESTful 231 | await api.post('/reclamacoes', formData, { 232 | headers: { 233 | 'Content-Type': 'multipart/form-data', 234 | Authorization: `Bearer ${token}`, 235 | }, 236 | }); 237 | 238 | // Sucesso - o feedback será exibido pelo modal 239 | // feedback.showSuccess('Reclamação enviada com sucesso!'); // REMOVIDO - duplicado 240 | // setTimeout(() => { 241 | // feedback.resetFeedback(); 242 | // fecharModal(); 243 | // }, 1800); // REMOVIDO - será gerenciado pelo modal 244 | } catch (e) { 245 | feedback.showError('Não foi possível enviar sua reclamação.'); 246 | console.log( 247 | 'Erro ao enviar reclamação:', 248 | e?.response?.data || e.message || e 249 | ); 250 | } finally { 251 | feedback.setLoading(false); 252 | } 253 | }} 254 | /> 255 | 256 | ); 257 | } 258 | -------------------------------------------------------------------------------- /backend/tests/validators.test.js: -------------------------------------------------------------------------------- 1 | import { assert, test, describe } from 'poku'; 2 | import { 3 | loginSchema, 4 | cadastroUsuarioSchema, 5 | cadastroEmpresaSchema, 6 | } from '../src/validators/authValidators.js'; 7 | import { 8 | criarReclamacaoSchema, 9 | responderReclamacaoSchema, 10 | avaliarReclamacaoSchema, 11 | } from '../src/validators/reclamacaoValidators.js'; 12 | 13 | describe('Testes Unitários de Validação Joi', () => { 14 | // ========== TESTES PARA VALIDADORES DE AUTENTICAÇÃO ========== 15 | 16 | describe('Login Schema', () => { 17 | test('Deve aceitar dados válidos de login', async () => { 18 | const dadosValidos = { 19 | email: 'usuario@teste.com', 20 | senha: '123456', 21 | }; 22 | 23 | const { error } = loginSchema.validate(dadosValidos); 24 | assert(!error, 'Não deve ter erro para dados válidos'); 25 | }); 26 | 27 | test('Deve falhar com email inválido', async () => { 28 | const dadosInvalidos = { 29 | email: 'email-sem-formato-valido', 30 | senha: '123456', 31 | }; 32 | 33 | const { error } = loginSchema.validate(dadosInvalidos); 34 | assert(error, 'Deve ter erro para email inválido'); 35 | assert( 36 | error.details[0].message.includes('e-mail'), 37 | 'Mensagem deve mencionar e-mail inválido' 38 | ); 39 | }); 40 | 41 | test('Deve falhar sem email', async () => { 42 | const dadosInvalidos = { 43 | senha: '123456', 44 | }; 45 | 46 | const { error } = loginSchema.validate(dadosInvalidos); 47 | assert(error, 'Deve ter erro quando email ausente'); 48 | assert( 49 | error.details[0].message.includes('obrigatório'), 50 | 'Mensagem deve mencionar que é obrigatório' 51 | ); 52 | }); 53 | 54 | test('Deve falhar com senha muito curta', async () => { 55 | const dadosInvalidos = { 56 | email: 'usuario@teste.com', 57 | senha: '123', 58 | }; 59 | 60 | const { error } = loginSchema.validate(dadosInvalidos); 61 | assert(error, 'Deve ter erro para senha muito curta'); 62 | assert( 63 | error.details[0].message.includes('6 caracteres'), 64 | 'Mensagem deve mencionar mínimo de 6 caracteres' 65 | ); 66 | }); 67 | }); 68 | 69 | describe('Cadastro de Usuário Schema', () => { 70 | test('Deve aceitar dados válidos de usuário', async () => { 71 | const dadosValidos = { 72 | nome: 'João Silva', 73 | email: 'joao@teste.com', 74 | senha: '123456', 75 | confirmarSenha: '123456', 76 | }; 77 | 78 | const { error } = cadastroUsuarioSchema.validate(dadosValidos); 79 | assert(!error, 'Não deve ter erro para dados válidos'); 80 | }); 81 | 82 | test('Deve falhar com nome contendo números', async () => { 83 | const dadosInvalidos = { 84 | nome: 'João123', 85 | email: 'joao@teste.com', 86 | senha: '123456', 87 | confirmarSenha: '123456', 88 | }; 89 | 90 | const { error } = cadastroUsuarioSchema.validate(dadosInvalidos); 91 | assert(error, 'Deve ter erro para nome com números'); 92 | assert( 93 | error.details[0].message.includes('letras'), 94 | 'Mensagem deve mencionar apenas letras' 95 | ); 96 | }); 97 | 98 | test('Deve falhar com senhas diferentes', async () => { 99 | const dadosInvalidos = { 100 | nome: 'João Silva', 101 | email: 'joao@teste.com', 102 | senha: '123456', 103 | confirmarSenha: '654321', 104 | }; 105 | 106 | const { error } = cadastroUsuarioSchema.validate(dadosInvalidos); 107 | assert(error, 'Deve ter erro para senhas diferentes'); 108 | assert( 109 | error.details[0].message.includes('coincidem'), 110 | 'Mensagem deve mencionar que senhas não coincidem' 111 | ); 112 | }); 113 | }); 114 | 115 | describe('Cadastro de Empresa Schema', () => { 116 | test('Deve aceitar nome de empresa com números e símbolos permitidos', async () => { 117 | const dadosValidos = { 118 | nome: 'Empresa XYZ 123 Ltda.', 119 | email: 'empresa@teste.com', 120 | senha: '123456', 121 | }; 122 | 123 | const { error } = cadastroEmpresaSchema.validate(dadosValidos); 124 | assert(!error, 'Não deve ter erro para nome válido de empresa'); 125 | }); 126 | 127 | test('Deve falhar com caracteres especiais não permitidos', async () => { 128 | const dadosInvalidos = { 129 | nome: 'Empresa@#$%', 130 | email: 'empresa@teste.com', 131 | senha: '123456', 132 | }; 133 | 134 | const { error } = cadastroEmpresaSchema.validate(dadosInvalidos); 135 | assert(error, 'Deve ter erro para caracteres especiais inválidos'); 136 | }); 137 | }); 138 | 139 | // ========== TESTES PARA VALIDADORES DE RECLAMAÇÃO ========== 140 | 141 | describe('Criar Reclamação Schema', () => { 142 | test('Deve aceitar dados válidos de reclamação', async () => { 143 | const dadosValidos = { 144 | titulo: 'Problema com produto', 145 | descricao: 'Descrição detalhada do problema encontrado', 146 | empresa: '507f1f77bcf86cd799439011', // ObjectId válido 147 | contato: 'usuario@teste.com', 148 | }; 149 | 150 | const { error } = criarReclamacaoSchema.validate(dadosValidos); 151 | assert(!error, 'Não deve ter erro para dados válidos'); 152 | }); 153 | 154 | test('Deve falhar com título muito curto', async () => { 155 | const dadosInvalidos = { 156 | titulo: 'Abc', 157 | descricao: 'Descrição detalhada do problema', 158 | empresa: '507f1f77bcf86cd799439011', 159 | contato: 'usuario@teste.com', 160 | }; 161 | 162 | const { error } = criarReclamacaoSchema.validate(dadosInvalidos); 163 | assert(error, 'Deve ter erro para título muito curto'); 164 | assert( 165 | error.details[0].message.includes('5 caracteres'), 166 | 'Mensagem deve mencionar mínimo de 5 caracteres' 167 | ); 168 | }); 169 | 170 | test('Deve aceitar contato como WhatsApp válido', async () => { 171 | const dadosValidos = { 172 | titulo: 'Problema com produto', 173 | descricao: 'Descrição detalhada do problema', 174 | empresa: '507f1f77bcf86cd799439011', 175 | contato: '(11)99999-9999', 176 | }; 177 | 178 | const { error } = criarReclamacaoSchema.validate(dadosValidos); 179 | assert(!error, 'Não deve ter erro para WhatsApp válido'); 180 | }); 181 | 182 | test('Deve falhar com contato inválido', async () => { 183 | const dadosInvalidos = { 184 | titulo: 'Problema com produto', 185 | descricao: 'Descrição detalhada do problema', 186 | empresa: '507f1f77bcf86cd799439011', 187 | contato: 'contato-invalido', 188 | }; 189 | 190 | const { error } = criarReclamacaoSchema.validate(dadosInvalidos); 191 | assert(error, 'Deve ter erro para contato inválido'); 192 | }); 193 | }); 194 | 195 | describe('Responder Reclamação Schema', () => { 196 | test('Deve aceitar resposta válida', async () => { 197 | const dadosValidos = { 198 | texto: 'Resposta detalhada da empresa para resolver o problema', 199 | }; 200 | 201 | const { error } = responderReclamacaoSchema.validate(dadosValidos); 202 | assert(!error, 'Não deve ter erro para resposta válida'); 203 | }); 204 | 205 | test('Deve falhar com resposta muito curta', async () => { 206 | const dadosInvalidos = { 207 | texto: 'Ok', 208 | }; 209 | 210 | const { error } = responderReclamacaoSchema.validate(dadosInvalidos); 211 | assert(error, 'Deve ter erro para resposta muito curta'); 212 | assert( 213 | error.details[0].message.includes('10 caracteres'), 214 | 'Mensagem deve mencionar mínimo de 10 caracteres' 215 | ); 216 | }); 217 | }); 218 | 219 | describe('Avaliar Reclamação Schema', () => { 220 | test('Deve aceitar avaliação válida', async () => { 221 | const dadosValidos = { 222 | estrelas: 4, 223 | problemaResolvido: true, 224 | comentario: 'Problema foi resolvido satisfatoriamente', 225 | }; 226 | 227 | const { error } = avaliarReclamacaoSchema.validate(dadosValidos); 228 | assert(!error, 'Não deve ter erro para avaliação válida'); 229 | }); 230 | 231 | test('Deve falhar com estrelas inválidas', async () => { 232 | const dadosInvalidos = { 233 | estrelas: 6, 234 | problemaResolvido: true, 235 | }; 236 | 237 | const { error } = avaliarReclamacaoSchema.validate(dadosInvalidos); 238 | assert(error, 'Deve ter erro para estrelas > 5'); 239 | assert( 240 | error.details[0].message.includes('5 estrelas'), 241 | 'Mensagem deve mencionar máximo de 5 estrelas' 242 | ); 243 | }); 244 | 245 | test('Deve falhar com comentário muito curto', async () => { 246 | const dadosInvalidos = { 247 | estrelas: 3, 248 | problemaResolvido: false, 249 | comentario: 'Ruim', 250 | }; 251 | 252 | const { error } = avaliarReclamacaoSchema.validate(dadosInvalidos); 253 | assert(error, 'Deve ter erro para comentário muito curto'); 254 | assert( 255 | error.details[0].message.includes('10 caracteres'), 256 | 'Mensagem deve mencionar mínimo de 10 caracteres' 257 | ); 258 | }); 259 | 260 | test('Deve aceitar avaliação sem comentário', async () => { 261 | const dadosValidos = { 262 | estrelas: 2, 263 | problemaResolvido: false, 264 | }; 265 | 266 | const { error } = avaliarReclamacaoSchema.validate(dadosValidos); 267 | assert(!error, 'Não deve ter erro para avaliação sem comentário'); 268 | }); 269 | }); 270 | }); 271 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 |

3 | Desatende 4 | 5 |

6 |

7 | DESATENDE 8 |

9 | 10 |
11 |

12 | JAVASCRIPT 13 | NODE 14 | EXPRESS.JS 15 | 16 | MONGODB 17 | MONGOOSE 18 | 19 | SWAGGER 20 | 21 |

JWT 22 | BCRYPT 23 | JOI 24 | 25 |

26 | 27 |

28 |

29 | React Native 30 | AXIOS 31 |

API para registro e resposta de reclamações

32 |

33 | 34 | 35 | 36 |
37 | 38 |

Objetivo do Projeto

39 |

Desatende é um app que desenvolvi sozinho para um projeto da faculdade, com backend focado em boas práticas e segurança. O nome “Desatende” une as palavras “desatenção” e “atende”, deixando claro o propósito: registrar falhas no atendimento em setores como restaurantes, faculdades, companhias aéreas e outros. Usuários podem cadastrar reclamações, detalhando o ocorrido e a localização, e as reclamações são organizadas por categoria, facilitando a busca por setor. Empresas e instituições podem responder publicamente, promovendo transparência e resolução. O objetivo é criar uma comunidade onde experiências reais ajudam a pressionar por melhorias no atendimento e elevar o padrão de serviço. 40 | O aplicativo não foi publicado em ambiente de produção como o Play Console e Render, sendo destinado exclusivamente a fins educacionais e sem fins lucrativos. 41 |

42 |

43 | DesatendeIndex 44 | 45 | Desatende 46 | 47 |

48 |
49 | 50 |

DOCS

51 |

52 | Com o backend rodando, acesse http://localhost:5000/api-docs no navegador para testar a API pela interface gráfica Swagger UI. 53 |

54 | docs 55 | 56 |

Autenticação e Segurança

57 |

A autenticação utiliza JWT para gerar e validar tokens de sessão de forma segura. Os tokens são assinados com uma chave secreta definida em variáveis de ambiente (dotenv), nunca expostos no código-fonte. As senhas dos usuários são validadas, possuem requisitos mínimos e são armazenadas já criptografadas usando bcrypt. O backend implementa validação de dados com Joi e limita tentativas abusivas de acesso através do express-rate-limit, protegendo a API contra ataques de força bruta e DDoS. Como o frontend é React Native, não há necessidade de configuração de CORS. O sistema possui fluxo completo de cadastro e autenticação, permitindo que novos usuários se registrem normalmente.

58 |

59 | 60 |

Telas e funcionalidades

O aplicativo possui fluxo de autenticação com telas de login e cadastro, tanto para usuários quanto empresas. Após login, a Home lista empresas disponíveis, permitindo abrir um modal para envio de reclamações utilizando o método POST na API. Usuários autenticados podem acessar o Perfil para visualizar suas reclamações, deletar abertas e avaliar respostas recebidas após interação da empresa. A tela de perfil também conta com botão de logout seguro.

No dashboard da empresa, é possível visualizar todas as reclamações recebidas. Cada reclamação pode ser respondida diretamente pelo dashboard, utilizando o método PATCH para editar o status e a resposta do registro. Todo o fluxo é baseado em autenticação via JWT e integração direta com as rotas protegidas do backend.

61 | Desatende 62 | 63 |

Stacks e principais tecnologias

O projeto utiliza React Native para a interface mobile, integração de APIs via Axios, backend construído em Node.js com Express e ESModules. O banco de dados é MongoDB, utilizando Mongoose como ODM.

A autenticação é baseada em JWT e as senhas são protegidas com bcrypt. O projeto adota Joi para validação de dados, express-rate-limit para limitar requisições e diversas outras bibliotecas para garantir boas práticas e segurança.

64 | app.js 65 | 66 |

Testes Automatizados

67 |

Os testes unitários foram implementados com o Poku leve, rápido e brasileiro! 🇧🇷 68 | Além disso, utilizei Thunder Client e HTTPie para testes manuais dos endpoints. 69 |

70 | 71 |

Como rodar o backend localmente

  1. Clone este repositório:
    git clone https://github.com/mmyersbyte/appdesatende
  2. Acesse a pasta do backend:
    cd backend
  3. Configure as variáveis de ambiente:
    Crie um arquivo .env com base no arquivo .env.example fornecido.
    Dica: Preencha o JWT_SECRET com um valor seguro para testar autenticação JWT.
  4. Instale as dependências:
    npm install
  5. Inicie o servidor:
    npm run dev
    ou
    node server.js
72 | 73 |

Como rodar o frontend localmente

  1. Pré-requisitos: Tenha o Node.js e o Expo CLI instalados em seu computador.
  2. Acesse a pasta do frontend:
    cd app
  3. Instale as dependências:
    npm install
  4. Configure a URL da API:
    Altere a BASE_URL para o endereço do seu backend local no arquivo app.json ou no arquivo de configuração correspondente.
  5. Execute o projeto:
    npx expo start
  6. Testando no seu celular físico:
    Instale o aplicativo Expo Go na Play Store/App Store, escaneie o QR Code do terminal e pronto!
  7. Testando no emulador:
    Instale o Android Studio, configure um emulador Android e rode o comando acima. O Expo vai detectar o emulador automaticamente.
74 | 75 |

Estrutura do Projeto

76 |
.
 77 | ├── backend
 78 | │   ├── .env
 79 | │   ├── package.json
 80 | │   ├── server.js
 81 | │   ├── swagger
 82 | │   │   ├── swagger.json
 83 | │   │   └── swagger.js
 84 | │   ├── tests
 85 | │   │   ├── auth.test.js
 86 | │   │   ├── validators.test.js
 87 | │   └── src
 88 | │       ├── app.js
 89 | │       ├── config
 90 | │       │   └── db.js
 91 | │       ├── controllers
 92 | │       │   ├── authEmpresa.Controller.js
 93 | │       │   ├── authUser.Controller.js
 94 | │       │   ├── empresa.Controller.js
 95 | │       │   ├── reclamacao.Controller.js
 96 | │       │   └── user.Controller.js
 97 | │       ├── middlewares
 98 | │       │   ├── auth.js
 99 | │       │   ├── errorHandler.js
100 | │       │   ├── notFoundHandler.js
101 | │       │   ├── rateLimiter.js
102 | │       │   ├── upload.js
103 | │       │   └── validate.js
104 | │       ├── models
105 | │       │   ├── Empresa.js
106 | │       │   ├── Reclamacao.js
107 | │       │   └── User.js
108 | │       ├── routes
109 | │       │   ├── empresa.js
110 | │       │   ├── reclamacao.js
111 | │       │   └── user.js
112 | │       └── validators
113 | │           ├── authValidators.js
114 | │           └── reclamacaoValidators.js
115 | ├── app
116 | │   ├── api
117 | │   │   ├── auth.js
118 | │   │   ├── axios.js
119 | │   │   ├── empresas.js
120 | │   │   └── reclamacao.js
121 | │   ├── components
122 | │   │   ├── AuthModal.jsx
123 | │   │   ├── CustomButton.jsx
124 | │   │   ├── EmpresaItem.jsx
125 | │   │   ├── Formulario.jsx
126 | │   │   ├── HeaderTitulo.jsx
127 | │   │   ├── LogoutButton.jsx
128 | │   │   ├── ModalAvaliarReclamacao.jsx
129 | │   │   ├── ModalCriarReclamacao.jsx
130 | │   │   ├── ModalRespostaReclamacao.jsx
131 | │   │   ├── ReclamacaoItem.jsx
132 | │   │   └── Rodape.jsx
133 | │   ├── estilos
134 | │   │   ├── estilosHome.js
135 | │   │   ├── estilosLogin.js
136 | │   │   ├── estilosPerfil.js
137 | │   │   └── estilosPerfilEmpresa.js
138 | │   ├── hooks
139 | │   │   ├── useAuth.js
140 | │   │   ├── useEmpresas.js
141 | │   │   ├── useFeedback.js
142 | │   │   ├── useImagePicker.js
143 | │   │   ├── useMinhasReclamacoes.js
144 | │   │   ├── useReclamacoesRecebidas.js
145 | │   │   └── useRefresh.js
146 | │   ├── dashboard.jsx
147 | │   ├── home.jsx
148 | │   ├── index.jsx
149 | │   └── perfil.jsx
150 | ├── app.js
151 | ├── app.json
152 | ├── package.json
153 | 
154 | -------------------------------------------------------------------------------- /app/index.jsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import { View, Text } from 'react-native'; 3 | import estilos from './estilos/estilosLogin'; 4 | import LottieView from 'lottie-react-native'; 5 | import { useRouter } from 'expo-router'; 6 | import CustomButton from './components/CustomButton'; 7 | import AuthModal from './components/AuthModal'; 8 | import Formulario from './components/Formulario'; 9 | import { FontAwesome } from '@expo/vector-icons'; 10 | 11 | // Adicione o import dos serviços de autenticação 12 | import { 13 | loginUsuario, 14 | loginEmpresa, 15 | cadastrarUsuario, 16 | cadastrarEmpresa, 17 | salvarToken, 18 | } from './src/api/auth'; 19 | import { useFeedback } from './src/hooks/useFeedback'; 20 | 21 | export default function Index() { 22 | // Estado para controlar qual modal (formulário) tá aberto: 'cliente', 'empresa', 'cadastro' ou null //(fechado) 23 | const [modalTipo, setModalTipo] = useState(null); 24 | // Estaos para os formulários de login (cliente/empresa) 25 | const [email, setEmail] = useState(''); 26 | const [senha, setSenha] = useState(''); 27 | // Estados para o formulário de cadastro 28 | const [nome, setNome] = useState(''); 29 | const [emailCadastro, setEmailCadastro] = useState(''); 30 | const [senhaCadastro, setSenhaCadastro] = useState(''); 31 | const [confirmarSenha, setConfirmarSenha] = useState(''); 32 | // Estado para controlar o tipo de cadastro: 'cliente' ou 'empresa' 33 | const [tipoCadastro, setTipoCadastro] = useState('cliente'); 34 | 35 | // Para navegar entre rotas do Expo 36 | const router = useRouter(); 37 | const feedback = useFeedback(); 38 | 39 | // Função para fechar o modal e limpar os campos 40 | const fecharModal = () => { 41 | setModalTipo(null); 42 | setEmail(''); 43 | setSenha(''); 44 | setNome(''); 45 | setEmailCadastro(''); 46 | setSenhaCadastro(''); 47 | setConfirmarSenha(''); 48 | }; 49 | 50 | // Handler para login 51 | const handleLogin = async () => { 52 | if (!email.trim() || !senha.trim()) { 53 | feedback.showError('Preencha todos os campos!'); 54 | return; 55 | } 56 | try { 57 | let data; 58 | if (modalTipo === 'cliente') { 59 | data = await loginUsuario({ email, senha }); 60 | } else { 61 | data = await loginEmpresa({ email, senha }); 62 | } 63 | await salvarToken(data.token); 64 | feedback.showSuccess('Login realizado!'); 65 | fecharModal(); 66 | if (modalTipo === 'cliente') { 67 | router.push('/home'); 68 | } else { 69 | router.push('/dashboard'); 70 | } 71 | } catch (error) { 72 | const msg = 73 | error.response?.data?.mensagem || error.response?.data?.message; 74 | if (msg === 'Email ou senha inválidos.') { 75 | feedback.showError('Senha inválida ou usuário não encontrado!'); 76 | } else { 77 | feedback.showError(msg || 'Erro ao fazer login'); 78 | } 79 | } 80 | }; 81 | 82 | // Handler para cadastro 83 | const handleCadastro = async () => { 84 | if ( 85 | !nome.trim() || 86 | !emailCadastro.trim() || 87 | !senhaCadastro.trim() || 88 | !confirmarSenha.trim() 89 | ) { 90 | feedback.showError('Preencha todos os campos!'); 91 | return; 92 | } 93 | if (senhaCadastro !== confirmarSenha) { 94 | feedback.showError('As senhas não coincidem!'); 95 | return; 96 | } 97 | try { 98 | if (tipoCadastro === 'empresa') { 99 | await cadastrarEmpresa({ 100 | nome, 101 | email: emailCadastro, 102 | senha: senhaCadastro, 103 | tipo: 'empresa', 104 | }); 105 | } else { 106 | await cadastrarUsuario({ 107 | nome, 108 | email: emailCadastro, 109 | senha: senhaCadastro, 110 | confirmarSenha, 111 | tipo: 'user', 112 | }); 113 | } 114 | feedback.showSuccess('Cadastro realizado!'); 115 | fecharModal(); 116 | } catch (error) { 117 | const msg = 118 | error.response?.data?.mensagem || error.response?.data?.message; 119 | if (msg === 'Já existe um usuário com este email.') { 120 | feedback.showError('Este email já está cadastrado!'); 121 | } else { 122 | feedback.showError(msg || 'Erro ao cadastrar'); 123 | } 124 | } 125 | }; 126 | 127 | // Renderiza o conteúdo do modal com base no tipo selecionado 128 | const renderizarFormulario = () => { 129 | // Formulário para login (cliente ou empresa) 130 | if (modalTipo === 'cliente' || modalTipo === 'empresa') { 131 | return ( 132 | 154 | ); 155 | } 156 | // Formulário de cadastro 157 | else if (modalTipo === 'cadastro') { 158 | return ( 159 | 192 | {/* Botões de seleção de tipo de cadastro */} 193 | 201 | 204 | 209 | 210 | } 211 | onPress={() => setTipoCadastro('cliente')} 212 | disabled={tipoCadastro === 'cliente'} 213 | height={45} 214 | width={20} 215 | cor='#D84040' 216 | /> 217 | 220 | 225 | 226 | } 227 | onPress={() => setTipoCadastro('empresa')} 228 | disabled={tipoCadastro === 'empresa'} 229 | height={45} 230 | width={20} 231 | cor='#D84040' 232 | /> 233 | 234 | 235 | 236 | Tipo selecionado:{' '} 237 | 240 | {tipoCadastro === 'empresa' ? 'Empresa' : 'Cliente'} 241 | 242 | 243 | 244 | ); 245 | } 246 | return null; 247 | }; 248 | 249 | // CONTEUDOS ------- 250 | // ------------------------------------------------------------------------------------------------ 251 | 252 | return ( 253 | 254 | {/* Feedback visual */} 255 | {feedback.error && ( 256 | 259 | {feedback.error} 260 | 261 | )} 262 | {feedback.success && ( 263 | 266 | {feedback.success} 267 | 268 | )} 269 | 270 | Seja bem-vindo ao Desatende 271 | ! 272 | 273 | {/* Subtítulo com descrição */} 274 | 275 | Já sofreu com um{' '} 276 | atendimento ruim? Compartilhe 277 | sua experiência e ajude a melhorar os serviços oferecidos! 278 | 279 | 280 | {/* Animação -- Lottie */} 281 | 287 | 288 | {/* Subtítulo 2 posicionado abaixo da animação e acima dos botões */} 289 | 290 | Comece agora! Escolha como deseja entrar. Esqueceu sua senha?{' '} 291 | Clique aqui 292 | 293 | 294 | {/* Botão de Login como Cliente */} 295 | setModalTipo('cliente')} 298 | cor='#D84040' 299 | /> 300 | 301 | {/* Botão de Login como Empresa */} 302 | setModalTipo('empresa')} 305 | cor='#D84040' 306 | /> 307 | 308 | {/* Botão de Cadastro */} 309 | setModalTipo('cadastro')} 312 | cor='#D84040' 313 | /> 314 | 315 | {/* Modal para exibir os formulários */} 316 | 321 | 322 | ); 323 | } 324 | -------------------------------------------------------------------------------- /app/components/ModalRespostaReclamacao.jsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import { 3 | Modal, 4 | View, 5 | Text, 6 | Pressable, 7 | TextInput, 8 | ScrollView, 9 | StyleSheet, 10 | Image, 11 | TouchableOpacity, 12 | ActivityIndicator, 13 | } from 'react-native'; 14 | import { FontAwesome, Ionicons } from '@expo/vector-icons'; 15 | 16 | const CORES = { 17 | fundoModal: 'rgba(0, 0, 0, 0.8)', 18 | fundoClaro: '#282a36', 19 | fundoMaisClaro: '#3a3f58', 20 | corPrimaria: '#44475a', 21 | corSecundaria: '#6272a4', 22 | textoPrincipal: '#f8f8f2', 23 | textoSuave: 'rgba(255, 255, 255, 0.7)', 24 | placeholder: '#8be9fd', 25 | }; 26 | 27 | const estilos = StyleSheet.create({ 28 | modalContainer: { 29 | flex: 1, 30 | justifyContent: 'center', 31 | backgroundColor: CORES.fundoModal, 32 | paddingHorizontal: 15, 33 | }, 34 | modalContent: { 35 | backgroundColor: CORES.fundoClaro, 36 | borderRadius: 15, 37 | maxHeight: '88%', 38 | elevation: 10, 39 | shadowColor: '#000', 40 | shadowOffset: { width: 0, height: 4 }, 41 | shadowOpacity: 0.3, 42 | shadowRadius: 8, 43 | }, 44 | modalHeader: { 45 | flexDirection: 'row', 46 | justifyContent: 'space-between', 47 | alignItems: 'center', 48 | padding: 18, 49 | backgroundColor: CORES.corPrimaria, 50 | borderTopLeftRadius: 15, 51 | borderTopRightRadius: 15, 52 | }, 53 | modalTitulo: { 54 | fontSize: 20, 55 | fontWeight: 'bold', 56 | color: '#fff', 57 | }, 58 | modalScrollView: { 59 | padding: 18, 60 | }, 61 | reclamacaoDetalhes: { 62 | marginBottom: 22, 63 | backgroundColor: CORES.fundoMaisClaro, 64 | padding: 16, 65 | borderRadius: 12, 66 | }, 67 | reclamacaoDetalheUsuario: { 68 | fontSize: 16, 69 | fontWeight: 'bold', 70 | color: CORES.textoPrincipal, 71 | marginBottom: 2, 72 | }, 73 | reclamacaoDetalheData: { 74 | fontSize: 13, 75 | color: CORES.textoSuave, 76 | marginBottom: 12, 77 | }, 78 | reclamacaoDetalheTitulo: { 79 | fontSize: 20, 80 | fontWeight: 'bold', 81 | color: CORES.textoPrincipal, 82 | marginBottom: 8, 83 | lineHeight: 26, 84 | }, 85 | reclamacaoDetalheDescricao: { 86 | fontSize: 15, 87 | color: CORES.textoPrincipal, 88 | lineHeight: 22, 89 | }, 90 | respostaContainer: { 91 | marginBottom: 22, 92 | }, 93 | respostaLabel: { 94 | fontSize: 18, 95 | fontWeight: 'bold', 96 | color: CORES.textoPrincipal, 97 | marginBottom: 12, 98 | }, 99 | respostaInput: { 100 | backgroundColor: CORES.fundoMaisClaro, 101 | borderRadius: 12, 102 | padding: 16, 103 | color: CORES.textoPrincipal, 104 | textAlignVertical: 'top', 105 | minHeight: 140, 106 | fontSize: 16, 107 | lineHeight: 22, 108 | borderWidth: 2, 109 | borderColor: 'transparent', 110 | }, 111 | respostaInputFocused: { 112 | borderColor: CORES.placeholder, 113 | }, 114 | modalBotoes: { 115 | flexDirection: 'row', 116 | justifyContent: 'space-between', 117 | gap: 12, 118 | marginTop: 8, 119 | marginBottom: 8, 120 | }, 121 | modalBotao: { 122 | flex: 1, 123 | paddingVertical: 16, 124 | paddingHorizontal: 20, 125 | borderRadius: 12, 126 | alignItems: 'center', 127 | elevation: 2, 128 | }, 129 | botaoCancelar: { 130 | backgroundColor: CORES.corSecundaria, 131 | }, 132 | botaoEnviar: { 133 | backgroundColor: CORES.corSecundaria, 134 | }, 135 | textoBotaoCancelar: { 136 | color: '#fff', 137 | fontWeight: 'bold', 138 | fontSize: 16, 139 | }, 140 | textoBotaoEnviar: { 141 | color: '#fff', 142 | fontWeight: 'bold', 143 | fontSize: 16, 144 | }, 145 | /** 146 | * Estilos para exibição de contato do cliente 147 | * Segue padrão visual do design system 148 | */ 149 | contatoContainer: { 150 | backgroundColor: CORES.fundoMaisClaro, 151 | padding: 14, 152 | borderRadius: 10, 153 | marginBottom: 15, 154 | borderLeftWidth: 4, 155 | borderLeftColor: CORES.placeholder, 156 | }, 157 | contatoLabel: { 158 | fontSize: 15, 159 | fontWeight: 'bold', 160 | color: CORES.placeholder, 161 | marginBottom: 6, 162 | }, 163 | contatoValor: { 164 | fontSize: 17, 165 | color: CORES.textoPrincipal, 166 | fontWeight: '600', 167 | marginBottom: 4, 168 | }, 169 | contatoDica: { 170 | fontSize: 13, 171 | color: CORES.textoSuave, 172 | fontStyle: 'italic', 173 | }, 174 | closeButton: { 175 | padding: 5, 176 | }, 177 | reclamacaoInfo: { 178 | backgroundColor: '#f8f9fa', 179 | padding: 15, 180 | borderRadius: 10, 181 | marginBottom: 20, 182 | }, 183 | reclamacaoTitulo: { 184 | fontSize: 16, 185 | fontWeight: '600', 186 | color: '#333', 187 | marginBottom: 8, 188 | }, 189 | reclamacaoDescricao: { 190 | fontSize: 14, 191 | color: '#666', 192 | lineHeight: 20, 193 | }, 194 | loadingContainer: { 195 | flexDirection: 'row', 196 | alignItems: 'center', 197 | gap: 8, 198 | }, 199 | }); 200 | 201 | export default function ModalRespostaReclamacao({ 202 | visible, 203 | onClose, 204 | reclamacao, 205 | resposta, 206 | setResposta, 207 | onEnviar, 208 | enviandoResposta = false, 209 | }) { 210 | const [focado, setFocado] = useState(false); 211 | 212 | const handleEnviar = () => { 213 | if (enviandoResposta) return; 214 | onEnviar(); 215 | }; 216 | 217 | const handleClose = () => { 218 | if (enviandoResposta) return; 219 | onClose(); 220 | }; 221 | 222 | return ( 223 | 229 | 230 | 231 | 232 | Responder Reclamação 233 | 241 | 246 | 247 | 248 | {reclamacao && ( 249 | 250 | 251 | 252 | Cliente: {reclamacao.user?.nome || 'Nome não informado'} 253 | 254 | 255 | Data:{' '} 256 | {reclamacao.createdAt 257 | ? new Date(reclamacao.createdAt).toLocaleDateString( 258 | 'pt-BR', 259 | { 260 | year: 'numeric', 261 | month: '2-digit', 262 | day: '2-digit', 263 | hour: '2-digit', 264 | minute: '2-digit', 265 | } 266 | ) 267 | : 'Data não informada'} 268 | 269 | 270 | {/** 271 | * Exibição do contato do cliente 272 | * Permite comunicação direta empresa-cliente 273 | */} 274 | {reclamacao.contato && ( 275 | 276 | 277 | Contato do Cliente: 278 | 279 | 280 | {reclamacao.contato} 281 | 282 | 283 | Use este contato para comunicação direta 284 | 285 | 286 | )} 287 | 288 | {/** 289 | * Exibição da imagem da reclamação 290 | * Renderizada em base64 conforme enviado pelo backend 291 | */} 292 | {reclamacao.imagem && reclamacao.imagem.data && ( 293 | 306 | )} 307 | 308 | {/** 309 | * Título e descrição da reclamação 310 | * Dados principais do problema relatado 311 | */} 312 | 313 | {reclamacao.titulo} 314 | 315 | 316 | {reclamacao.descricao} 317 | 318 | 319 | 320 | Sua Resposta: 321 | setFocado(true)} 334 | onBlur={() => setFocado(false)} 335 | editable={!enviandoResposta} 336 | /> 337 | 338 | 339 | 344 | Cancelar 345 | 346 | 351 | {enviandoResposta ? ( 352 | 353 | 357 | Enviando... 358 | 359 | ) : ( 360 | 361 | Enviar Resposta 362 | 363 | )} 364 | 365 | 366 | 367 | )} 368 | 369 | 370 | 371 | ); 372 | } 373 | -------------------------------------------------------------------------------- /app/components/ModalAvaliarReclamacao.jsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from 'react'; 2 | import { 3 | Modal, 4 | View, 5 | Text, 6 | Pressable, 7 | TextInput, 8 | ScrollView, 9 | StyleSheet, 10 | Alert, 11 | Switch, 12 | } from 'react-native'; 13 | import { FontAwesome } from '@expo/vector-icons'; 14 | 15 | /** 16 | * DESIGN SYSTEM - CORES 17 | * Paleta Dracula harmônica e elegante 18 | */ 19 | const CORES = { 20 | fundoModal: 'rgba(0, 0, 0, 0.8)', 21 | fundoClaro: '#282a36', 22 | fundoMaisClaro: '#3a3f58', 23 | corPrimaria: '#44475a', 24 | corSecundaria: '#6272a4', 25 | corSucesso: '#50fa7b', 26 | corAlerta: '#FF9800', 27 | textoPrincipal: '#f8f8f2', 28 | textoSuave: 'rgba(255, 255, 255, 0.7)', 29 | estrelaAtiva: '#f1fa8c', 30 | estrelaInativa: '#6272a4', 31 | }; 32 | 33 | const estilos = StyleSheet.create({ 34 | modalContainer: { 35 | flex: 1, 36 | justifyContent: 'center', 37 | backgroundColor: CORES.fundoModal, 38 | }, 39 | modalContent: { 40 | backgroundColor: CORES.fundoClaro, 41 | margin: 20, 42 | borderRadius: 12, 43 | maxHeight: '85%', 44 | elevation: 5, 45 | shadowColor: '#000', 46 | shadowOffset: { width: 0, height: 2 }, 47 | shadowOpacity: 0.25, 48 | shadowRadius: 3.84, 49 | }, 50 | modalHeader: { 51 | flexDirection: 'row', 52 | justifyContent: 'space-between', 53 | alignItems: 'center', 54 | padding: 16, 55 | backgroundColor: CORES.corPrimaria, 56 | borderTopLeftRadius: 12, 57 | borderTopRightRadius: 12, 58 | }, 59 | modalTitulo: { 60 | fontSize: 18, 61 | fontWeight: 'bold', 62 | color: CORES.textoPrincipal, 63 | }, 64 | modalScrollView: { 65 | padding: 16, 66 | }, 67 | 68 | // Informações da reclamação 69 | reclamacaoInfo: { 70 | backgroundColor: CORES.fundoMaisClaro, 71 | padding: 12, 72 | borderRadius: 8, 73 | marginBottom: 20, 74 | borderLeftWidth: 4, 75 | borderLeftColor: CORES.corPrimaria, 76 | }, 77 | reclamacaoTitulo: { 78 | fontSize: 16, 79 | fontWeight: 'bold', 80 | color: CORES.textoPrincipal, 81 | marginBottom: 4, 82 | }, 83 | reclamacaoEmpresa: { 84 | fontSize: 14, 85 | color: CORES.textoSuave, 86 | }, 87 | 88 | // Sistema de estrelas 89 | avaliacaoContainer: { 90 | marginBottom: 24, 91 | }, 92 | avaliacaoLabel: { 93 | fontSize: 16, 94 | fontWeight: 'bold', 95 | color: CORES.textoPrincipal, 96 | marginBottom: 12, 97 | textAlign: 'center', 98 | }, 99 | estrelasContainer: { 100 | flexDirection: 'row', 101 | justifyContent: 'center', 102 | marginBottom: 8, 103 | }, 104 | estrela: { 105 | marginHorizontal: 8, 106 | padding: 4, 107 | }, 108 | avaliacaoTexto: { 109 | textAlign: 'center', 110 | fontSize: 14, 111 | color: CORES.textoSuave, 112 | marginTop: 8, 113 | }, 114 | 115 | // Toggle problema resolvido 116 | problemaContainer: { 117 | flexDirection: 'row', 118 | justifyContent: 'space-between', 119 | alignItems: 'center', 120 | backgroundColor: CORES.fundoMaisClaro, 121 | padding: 16, 122 | borderRadius: 8, 123 | marginBottom: 20, 124 | }, 125 | problemaTexto: { 126 | fontSize: 16, 127 | color: CORES.textoPrincipal, 128 | fontWeight: '600', 129 | flex: 1, 130 | }, 131 | problemaSubtexto: { 132 | fontSize: 12, 133 | color: CORES.textoSuave, 134 | marginTop: 2, 135 | }, 136 | 137 | // Campo de comentário 138 | comentarioContainer: { 139 | marginBottom: 24, 140 | }, 141 | comentarioLabel: { 142 | fontSize: 14, 143 | fontWeight: 'bold', 144 | color: CORES.textoPrincipal, 145 | marginBottom: 8, 146 | }, 147 | comentarioInput: { 148 | backgroundColor: CORES.fundoMaisClaro, 149 | borderRadius: 8, 150 | padding: 12, 151 | color: CORES.textoPrincipal, 152 | textAlignVertical: 'top', 153 | minHeight: 100, 154 | borderWidth: 1, 155 | borderColor: 'transparent', 156 | }, 157 | comentarioFocused: { 158 | borderColor: CORES.corPrimaria, 159 | }, 160 | comentarioInfo: { 161 | fontSize: 12, 162 | color: CORES.textoSuave, 163 | marginTop: 4, 164 | }, 165 | 166 | // Botões de ação 167 | modalBotoes: { 168 | flexDirection: 'row', 169 | justifyContent: 'space-between', 170 | marginTop: 16, 171 | }, 172 | modalBotao: { 173 | flex: 1, 174 | padding: 14, 175 | borderRadius: 8, 176 | alignItems: 'center', 177 | }, 178 | botaoCancelar: { 179 | backgroundColor: CORES.corSecundaria, 180 | marginRight: 8, 181 | }, 182 | botaoEnviar: { 183 | backgroundColor: CORES.corSecundaria, 184 | marginLeft: 8, 185 | }, 186 | botaoDesabilitado: { 187 | backgroundColor: '#555', 188 | opacity: 0.6, 189 | }, 190 | textoBotao: { 191 | fontWeight: 'bold', 192 | fontSize: 16, 193 | }, 194 | textoBotaoCancelar: { 195 | color: '#fff', 196 | }, 197 | textoBotaoEnviar: { 198 | color: '#fff', 199 | }, 200 | }); 201 | 202 | /** 203 | * 🌟 COMPONENTE MODAL DE AVALIAÇÃO 204 | * 205 | * FUNCIONALIDADES: 206 | * ✅ Sistema de 5 estrelas interativo 207 | * ✅ Toggle para indicar se problema foi resolvido 208 | * ✅ Campo de comentário opcional 209 | * ✅ Validações em tempo real 210 | * ✅ Feedback visual para o usuário 211 | * ✅ Integração com API de avaliação 212 | * 213 | * @param {boolean} visible - Controla visibilidade do modal 214 | * @param {Function} onClose - Callback para fechar modal 215 | * @param {Object} reclamacao - Dados da reclamação a ser avaliada 216 | * @param {Function} onAvaliar - Callback para enviar avaliação 217 | */ 218 | export default function ModalAvaliarReclamacao({ 219 | visible, 220 | onClose, 221 | reclamacao, 222 | onAvaliar, 223 | }) { 224 | // Estados do componente 225 | const [estrelas, setEstrelas] = useState(0); 226 | const [problemaResolvido, setProblemaResolvido] = useState(null); 227 | const [comentario, setComentario] = useState(''); 228 | const [comentarioFocused, setComentarioFocused] = useState(false); 229 | const [enviando, setEnviando] = useState(false); 230 | 231 | /** 232 | * RESET: Limpa estados quando modal abre/fecha 233 | * Garante que dados anteriores não permaneçam 234 | */ 235 | useEffect(() => { 236 | if (visible) { 237 | // Reset states when modal opens 238 | setEstrelas(0); 239 | setProblemaResolvido(null); 240 | setComentario(''); 241 | setEnviando(false); 242 | } 243 | }, [visible]); 244 | 245 | /** 246 | * LABELS DINÂMICOS: Texto baseado na quantidade de estrelas 247 | * Feedback imediato para o usuário 248 | */ 249 | const getAvaliacaoTexto = (numEstrelas) => { 250 | const textos = { 251 | 0: 'Toque nas estrelas para avaliar', 252 | 1: 'Muito insatisfeito', 253 | 2: 'Insatisfeito', 254 | 3: 'Neutro', 255 | 4: 'Satisfeito', 256 | 5: 'Muito satisfeito', 257 | }; 258 | return textos[numEstrelas] || ''; 259 | }; 260 | 261 | /** 262 | * VALIDAÇÃO: Verifica se dados são válidos para envio 263 | * Garante que campos obrigatórios estão preenchidos 264 | */ 265 | const dadosValidos = () => { 266 | return estrelas >= 1 && estrelas <= 5 && problemaResolvido !== null; 267 | }; 268 | 269 | /** 270 | * HANDLER: Envio da avaliação 271 | * Valida dados, chama callback e executa refresh 272 | */ 273 | const handleEnviarAvaliacao = async () => { 274 | if (!dadosValidos()) { 275 | if (estrelas < 1 || estrelas > 5) { 276 | Alert.alert( 277 | 'Avaliação Incompleta', 278 | 'Por favor, selecione uma avaliação de 1 a 5 estrelas.', 279 | [{ text: 'OK' }] 280 | ); 281 | return; 282 | } 283 | 284 | if (problemaResolvido === null) { 285 | Alert.alert( 286 | 'Informação Obrigatória', 287 | 'Por favor, informe se seu problema foi resolvido ou não.', 288 | [{ text: 'OK' }] 289 | ); 290 | return; 291 | } 292 | } 293 | 294 | // Validação adicional do comentário se preenchido 295 | if (comentario.trim().length > 0 && comentario.trim().length < 10) { 296 | Alert.alert( 297 | 'Comentário Muito Curto', 298 | 'Se preenchido, o comentário deve ter pelo menos 10 caracteres.', 299 | [{ text: 'OK' }] 300 | ); 301 | return; 302 | } 303 | 304 | try { 305 | setEnviando(true); 306 | 307 | /** 308 | * DADOS DA AVALIAÇÃO 309 | * Estrutura enviada para a API 310 | */ 311 | const dadosAvaliacao = { 312 | estrelas: estrelas, 313 | problemaResolvido: problemaResolvido, 314 | comentario: comentario.trim() || undefined, 315 | }; 316 | 317 | await onAvaliar(dadosAvaliacao); 318 | 319 | // Sucesso - modal será fechado pelo componente pai 320 | Alert.alert( 321 | 'Avaliação Enviada! ⭐', 322 | `Sua avaliação de ${estrelas} ${ 323 | estrelas === 1 ? 'estrela' : 'estrelas' 324 | } foi registrada com sucesso.`, 325 | [{ text: 'OK' }] 326 | ); 327 | } catch (error) { 328 | Alert.alert( 329 | 'Erro ao Avaliar', 330 | error.message || 331 | 'Não foi possível enviar sua avaliação. Tente novamente.', 332 | [{ text: 'OK' }] 333 | ); 334 | } finally { 335 | setEnviando(false); 336 | } 337 | }; 338 | 339 | /** 340 | * RENDER: Interface do modal 341 | */ 342 | return ( 343 | 349 | 350 | 351 | {/* Header do Modal */} 352 | 353 | Avaliar Atendimento 354 | 358 | 363 | 364 | 365 | 366 | {/* Conteúdo do Modal */} 367 | 368 | {/* Informações da Reclamação */} 369 | {reclamacao && ( 370 | 371 | 372 | {reclamacao.titulo} 373 | 374 | 375 | Empresa: {reclamacao.empresa?.nome || 'Não informado'} 376 | 377 | 378 | )} 379 | 380 | {/* Sistema de Avaliação por Estrelas */} 381 | 382 | 383 | Como você avalia o atendimento? 384 | 385 | 386 | 387 | {[1, 2, 3, 4, 5].map((numero) => ( 388 | setEstrelas(numero)} 392 | disabled={enviando} 393 | > 394 | 403 | 404 | ))} 405 | 406 | 407 | 408 | {getAvaliacaoTexto(estrelas)} 409 | 410 | 411 | 412 | {/* Toggle Problema Resolvido */} 413 | 422 | 423 | 429 | Seu problema foi resolvido? * 430 | 431 | 432 | {problemaResolvido === null 433 | ? 'Obrigatório - Selecione uma opção' 434 | : 'Essa informação ajuda outras pessoas'} 435 | 436 | 437 | 447 | 448 | 449 | {/* Campo de Comentário Opcional */} 450 | 451 | Comentário (opcional) 452 | setComentarioFocused(true)} 464 | onBlur={() => setComentarioFocused(false)} 465 | maxLength={500} 466 | editable={!enviando} 467 | /> 468 | 469 | {comentario.length}/500 caracteres 470 | {comentario.trim().length > 0 && comentario.trim().length < 10 471 | ? ' • Mínimo 10 caracteres' 472 | : ''} 473 | 474 | 475 | 476 | {/* Botões de Ação */} 477 | 478 | 483 | 484 | Cancelar 485 | 486 | 487 | 488 | 497 | 498 | {enviando ? 'Enviando...' : 'Enviar Avaliação'} 499 | 500 | 501 | 502 | 503 | 504 | 505 | 506 | ); 507 | } 508 | -------------------------------------------------------------------------------- /app/components/ModalCriarReclamacao.jsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import { 3 | Modal, 4 | View, 5 | Text, 6 | Image, 7 | TextInput, 8 | KeyboardAvoidingView, 9 | Platform, 10 | ScrollView, 11 | Pressable, 12 | } from 'react-native'; 13 | import { FontAwesome } from '@expo/vector-icons'; 14 | import LottieView from 'lottie-react-native'; 15 | import { useImagePicker } from '../src/hooks/useImagePicker'; 16 | import CustomButton from './CustomButton'; 17 | import { useFeedback } from '../src/hooks/useFeedback'; 18 | 19 | // PALETA DRACULA PARA MODAL CRIAR RECLAMAÇÃO 20 | const CORES = { 21 | // Fundos 22 | fundoModal: 'rgba(0, 0, 0, 0.8)', 23 | fundoPrincipal: '#282a36', // Dracula background 24 | fundoInput: '#44475a', // Dracula current line 25 | fundoImagem: '#B6B09F', 26 | 27 | // Textos 28 | textoPrincipal: '#f8f8f2', // Dracula foreground 29 | textoSecundario: '#6272a4', // Dracula comment 30 | textoSuave: 'rgba(255, 255, 255, 0.7)', 31 | placeholder: '#6272a4', // Dracula comment 32 | 33 | // Cores de ação 34 | sucesso: '#50fa7b', // Dracula green 35 | erro: '#ff5555', // Dracula red 36 | alerta: '#ffb86c', // Dracula orange 37 | primaria: '#8be9fd', // Dracula cyan 38 | secundaria: '#bd93f9', // Dracula purple 39 | 40 | // Botões 41 | botaoEnviar: '#50fa7b', // Verde para ação principal 42 | botaoCancelar: '#6272a4', // Cinza para cancelar 43 | botaoRemover: '#ff5555', // Vermelho para remover 44 | botaoImagem: '#bd93f9', // Purple para imagem 45 | }; 46 | 47 | /** 48 | * ModalCriarReclamacao 49 | * Componente reutilizável para criar reclamação para uma empresa 50 | * Props: 51 | * - visible: boolean (se o modal está aberto) 52 | * - empresa: objeto (dados da empresa selecionada) 53 | * - onClose: função (para fechar o modal) 54 | * - onSubmit: função async (dados, imagemUri) => {} (para enviar a reclamação) 55 | */ 56 | export default function ModalCriarReclamacao({ 57 | visible, 58 | empresa, 59 | onClose, 60 | onSubmit, 61 | }) { 62 | // Estados do formulário 63 | const [titulo, setTitulo] = useState(''); 64 | const [descricao, setDescricao] = useState(''); 65 | 66 | /** 67 | * Estado para campo de contato (email ou WhatsApp) 68 | * Implementa comunicação direta entre cliente e empresa 69 | */ 70 | const [contato, setContato] = useState(''); 71 | 72 | const [enviando, setEnviando] = useState(false); 73 | const [sucessoEnvio, setSucessoEnvio] = useState(false); 74 | 75 | // Hook para seleção de imagem 76 | const { imagem, setImagem, selecionarImagem } = useImagePicker(); 77 | 78 | // Hook para feedbacks 79 | const feedback = useFeedback(); 80 | 81 | /** 82 | * Função de limpeza do formulário 83 | * Reseta todos os estados para valores iniciais 84 | */ 85 | const limparFormulario = () => { 86 | setTitulo(''); 87 | setDescricao(''); 88 | setContato(''); // Reset do campo contato 89 | setImagem(null); 90 | setSucessoEnvio(false); 91 | }; 92 | 93 | /** 94 | * Validação específica do campo contato 95 | * Implementa regras de negócio no lado cliente 96 | */ 97 | const validarContato = (valor) => { 98 | if (!valor || !valor.trim()) { 99 | return 'Contato é obrigatório. Digite seu email ou WhatsApp.'; 100 | } 101 | 102 | const contatoTrimmed = valor.trim(); 103 | 104 | // Validação de comprimento mínimo 105 | if (contatoTrimmed.length < 5) { 106 | return 'Contato deve ter pelo menos 5 caracteres.'; 107 | } 108 | 109 | // Regex para validação de email 110 | const emailRegex = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/; 111 | 112 | // Regex para validação de WhatsApp (flexível para diferentes formatos) 113 | const whatsappRegex = 114 | /^(?:\+?55\s?)?(?:\(?\d{2}\)?[\s-]?)(?:9\d{4}[\s-]?\d{4}|\d{4}[\s-]?\d{4})$/; 115 | 116 | const isValidEmail = emailRegex.test(contatoTrimmed); 117 | const isValidWhatsApp = whatsappRegex.test(contatoTrimmed); 118 | 119 | if (!isValidEmail && !isValidWhatsApp) { 120 | return 'Digite um email válido ou WhatsApp no formato (11)99999-9999'; 121 | } 122 | 123 | return null; // Válido 124 | }; 125 | 126 | // Handler de envio 127 | const handleEnviar = async () => { 128 | // Validação dos campos obrigatórios 129 | if (!titulo.trim() || !descricao.trim() || !contato.trim()) { 130 | feedback.showError('Preencha todos os campos obrigatórios.'); 131 | return; 132 | } 133 | 134 | if (titulo.trim().length < 5 || descricao.trim().length < 5) { 135 | feedback.showError('Título e descrição devem ter no mínimo 5 letras.'); 136 | return; 137 | } 138 | 139 | /** 140 | * Validação obrigatória do campo contato 141 | * Aplica validação client-side antes do envio 142 | */ 143 | const erroContato = validarContato(contato); 144 | if (erroContato) { 145 | feedback.showError(erroContato); 146 | return; 147 | } 148 | 149 | // Impede reclamação duplicada consecutiva 150 | const ultimaReclamacao = global.ultimaReclamacao || {}; 151 | if ( 152 | ultimaReclamacao.empresaId === empresa?.id && 153 | ultimaReclamacao.titulo === titulo.trim() && 154 | ultimaReclamacao.descricao === descricao.trim() 155 | ) { 156 | feedback.showError( 157 | 'Você não pode reclamar da mesma empresa duas vezes seguidas com o mesmo conteúdo.' 158 | ); 159 | return; 160 | } 161 | 162 | try { 163 | feedback.setLoading(true); 164 | 165 | /** 166 | * Envio dos dados incluindo o campo contato obrigatório 167 | * Mantém compatibilidade com API existente 168 | */ 169 | await onSubmit( 170 | { 171 | titulo, 172 | descricao, 173 | contato: contato.trim(), // Agora é obrigatório 174 | empresaId: empresa?.id, 175 | }, 176 | imagem 177 | ); 178 | 179 | // Não mostra mais alert - tela visual de sucesso é suficiente 180 | // feedback.showSuccess('Reclamação enviada com sucesso!'); // REMOVIDO 181 | 182 | // Salva última reclamação globalmente para evitar duplicidade consecutiva 183 | global.ultimaReclamacao = { 184 | empresaId: empresa?.id, 185 | titulo: titulo.trim(), 186 | descricao: descricao.trim(), 187 | }; 188 | 189 | setSucessoEnvio(true); 190 | setTimeout(() => { 191 | limparFormulario(); 192 | onClose(); 193 | feedback.resetFeedback(); 194 | }, 5800); // Aumentado de 1800ms para 5800ms (mais 4 segundos) 195 | } catch (e) { 196 | feedback.showError('Não foi possível enviar sua reclamação.'); 197 | } finally { 198 | feedback.setLoading(false); 199 | } 200 | }; 201 | 202 | // Fecha o modal e limpa 203 | const handleClose = () => { 204 | limparFormulario(); 205 | onClose(); 206 | }; 207 | 208 | return ( 209 | 215 | 224 | 235 | {/* Feedback visual - apenas erros são mostrados */} 236 | {feedback.error && ( 237 | 244 | {feedback.error} 245 | 246 | )} 247 | {/* Removido feedback.success - tela visual é melhor */} 248 | {/* Dados da empresa */} 249 | {empresa && !sucessoEnvio && ( 250 | 251 | 265 | 273 | Nova Reclamação para {empresa.nome} 274 | 275 | 283 | Breve descrição lorem ipsum dolor sit amet. 284 | 285 | 286 | )} 287 | {/* Formulário de reclamação */} 288 | {sucessoEnvio ? ( 289 | 299 | {/* Botão X para fechar */} 300 | 312 | 317 | 318 | 319 | {/* Ícone de sucesso simplificado */} 320 | 330 | 335 | 336 | 337 | {/* Título principal */} 338 | 348 | Reclamação Enviada! 349 | 350 | 351 | {/* Mensagem informativa simplificada */} 352 | 361 | A empresa será notificada e você receberá uma resposta em breve. 362 | 363 | 364 | {/* LottieView animação */} 365 | 374 | 375 | ) : ( 376 | 377 | 378 | 385 | Título * 386 | 387 | 400 | 401 | 402 | 409 | Descrição * 410 | 411 | 426 | 427 | {/* Campo de contato obrigatório */} 428 | 429 | 436 | Contato para Resposta * 437 | 438 | 452 | 460 | 💬 A empresa usará este contato para responder sua reclamação 461 | 462 | 463 | {/* Campo de upload de imagem opcional */} 464 | 465 | 472 | Imagem (opcional) 473 | 474 | {imagem ? ( 475 | 476 | 485 | setImagem(null)} 488 | height={40} 489 | width={140} 490 | cor={CORES.botaoRemover} 491 | /> 492 | 493 | ) : ( 494 | 501 | )} 502 | 503 | 511 | 519 | 520 | )} 521 | 522 | 523 | 524 | ); 525 | } 526 | -------------------------------------------------------------------------------- /backend/swagger/swagger.json: -------------------------------------------------------------------------------- 1 | { 2 | "openapi": "3.0.0", 3 | "info": { 4 | "title": "Desatende API", 5 | "version": "1.0.0", 6 | "description": "API para gerenciamento de reclamações desenvolvido por Pedro Victor" 7 | }, 8 | "servers": [ 9 | { 10 | "url": "http://localhost:5000/api", 11 | "description": "Servidor de desenvolvimento" 12 | } 13 | ], 14 | "paths": { 15 | "/auth/login": { 16 | "post": { 17 | "tags": ["Auth"], 18 | "summary": "Login de usuário ou empresa", 19 | "requestBody": { 20 | "required": true, 21 | "content": { 22 | "application/json": { 23 | "schema": { 24 | "type": "object", 25 | "properties": { 26 | "email": { "type": "string", "example": "usuario@teste.com" }, 27 | "senha": { "type": "string", "example": "123456" } 28 | }, 29 | "required": ["email", "senha"] 30 | } 31 | } 32 | } 33 | }, 34 | "responses": { 35 | "200": { 36 | "description": "Login realizado com sucesso", 37 | "content": { 38 | "application/json": { 39 | "schema": { 40 | "type": "object", 41 | "properties": { 42 | "success": { "type": "boolean", "example": true }, 43 | "token": { "type": "string" }, 44 | "tipo": { "type": "string", "example": "user" }, 45 | "usuario": { "type": "object" } 46 | } 47 | } 48 | } 49 | } 50 | }, 51 | "400": { 52 | "description": "Dados inválidos" 53 | }, 54 | "401": { 55 | "description": "Credenciais incorretas" 56 | } 57 | } 58 | } 59 | }, 60 | "/auth/cadastro/usuario": { 61 | "post": { 62 | "tags": ["Auth"], 63 | "summary": "Cadastro de usuário", 64 | "requestBody": { 65 | "required": true, 66 | "content": { 67 | "application/json": { 68 | "schema": { 69 | "type": "object", 70 | "properties": { 71 | "nome": { "type": "string", "example": "João Silva" }, 72 | "email": { "type": "string", "example": "joao@teste.com" }, 73 | "senha": { "type": "string", "example": "123456" }, 74 | "confirmarSenha": { "type": "string", "example": "123456" } 75 | }, 76 | "required": ["nome", "email", "senha", "confirmarSenha"] 77 | } 78 | } 79 | } 80 | }, 81 | "responses": { 82 | "201": { 83 | "description": "Usuário cadastrado com sucesso" 84 | }, 85 | "400": { 86 | "description": "Dados inválidos" 87 | }, 88 | "409": { 89 | "description": "Email já cadastrado" 90 | } 91 | } 92 | } 93 | }, 94 | "/auth/cadastro/empresa": { 95 | "post": { 96 | "tags": ["Auth"], 97 | "summary": "Cadastro de empresa", 98 | "requestBody": { 99 | "required": true, 100 | "content": { 101 | "application/json": { 102 | "schema": { 103 | "type": "object", 104 | "properties": { 105 | "nome": { "type": "string", "example": "Empresa X" }, 106 | "email": { "type": "string", "example": "empresa@teste.com" }, 107 | "senha": { "type": "string", "example": "123456" }, 108 | "confirmarSenha": { "type": "string", "example": "123456" } 109 | }, 110 | "required": ["nome", "email", "senha", "confirmarSenha"] 111 | } 112 | } 113 | } 114 | }, 115 | "responses": { 116 | "201": { 117 | "description": "Empresa cadastrada com sucesso" 118 | }, 119 | "400": { 120 | "description": "Dados inválidos" 121 | }, 122 | "409": { 123 | "description": "Email já cadastrado" 124 | } 125 | } 126 | } 127 | }, 128 | "/empresas/listar-empresas": { 129 | "get": { 130 | "tags": ["Empresas"], 131 | "summary": "Listar todas as empresas", 132 | "description": "Retorna uma lista de todas as empresas cadastradas.", 133 | "responses": { 134 | "200": { 135 | "description": "Lista de empresas retornada com sucesso", 136 | "content": { 137 | "application/json": { 138 | "schema": { 139 | "type": "array", 140 | "items": { 141 | "type": "object", 142 | "properties": { 143 | "_id": { 144 | "type": "string", 145 | "example": "507f1f77bcf86cd799439012" 146 | }, 147 | "nome": { "type": "string", "example": "Empresa X" }, 148 | "email": { 149 | "type": "string", 150 | "example": "empresa@teste.com" 151 | }, 152 | "descricao": { 153 | "type": "string", 154 | "example": "Empresa de tecnologia" 155 | }, 156 | "imagem": { 157 | "type": "string", 158 | "example": "https://exemplo.com/logo.png" 159 | } 160 | } 161 | } 162 | } 163 | } 164 | } 165 | } 166 | } 167 | } 168 | }, 169 | "/empresas/{id}": { 170 | "get": { 171 | "tags": ["Empresas"], 172 | "summary": "Buscar empresa por ID", 173 | "description": "Retorna os dados de uma empresa específica pelo seu ID.", 174 | "parameters": [ 175 | { 176 | "name": "id", 177 | "in": "path", 178 | "required": true, 179 | "description": "ID da empresa", 180 | "schema": { 181 | "type": "string", 182 | "example": "507f1f77bcf86cd799439012" 183 | } 184 | } 185 | ], 186 | "responses": { 187 | "200": { 188 | "description": "Dados da empresa retornados com sucesso", 189 | "content": { 190 | "application/json": { 191 | "schema": { 192 | "type": "object", 193 | "properties": { 194 | "_id": { 195 | "type": "string", 196 | "example": "507f1f77bcf86cd799439012" 197 | }, 198 | "nome": { "type": "string", "example": "Empresa X" }, 199 | "email": { 200 | "type": "string", 201 | "example": "empresa@teste.com" 202 | }, 203 | "descricao": { 204 | "type": "string", 205 | "example": "Empresa de tecnologia" 206 | }, 207 | "imagem": { 208 | "type": "string", 209 | "example": "https://exemplo.com/logo.png" 210 | } 211 | } 212 | } 213 | } 214 | } 215 | }, 216 | "404": { 217 | "description": "Empresa não encontrada" 218 | } 219 | } 220 | } 221 | }, 222 | "/users/listar-usuarios": { 223 | "get": { 224 | "tags": ["Usuários"], 225 | "summary": "Listar todos os usuários", 226 | "description": "Retorna uma lista de todos os usuários cadastrados.", 227 | "responses": { 228 | "200": { 229 | "description": "Lista de usuários retornada com sucesso", 230 | "content": { 231 | "application/json": { 232 | "schema": { 233 | "type": "array", 234 | "items": { 235 | "type": "object", 236 | "properties": { 237 | "_id": { 238 | "type": "string", 239 | "example": "507f1f77bcf86cd799439011" 240 | }, 241 | "nome": { "type": "string", "example": "João Silva" }, 242 | "email": { "type": "string", "example": "joao@teste.com" } 243 | } 244 | } 245 | } 246 | } 247 | } 248 | } 249 | } 250 | } 251 | }, 252 | "/reclamacoes": { 253 | "post": { 254 | "tags": ["Ciclo de Reclamação"], 255 | "summary": "Criar uma nova reclamação", 256 | "description": "Usuário autenticado pode criar uma reclamação para uma empresa.", 257 | "requestBody": { 258 | "required": true, 259 | "content": { 260 | "application/json": { 261 | "schema": { 262 | "type": "object", 263 | "properties": { 264 | "titulo": { "type": "string", "example": "Atendimento ruim" }, 265 | "descricao": { 266 | "type": "string", 267 | "example": "Fui mal atendido na loja X" 268 | }, 269 | "contato": { "type": "string", "example": "(11) 99999-9999" }, 270 | "empresa": { 271 | "type": "string", 272 | "example": "507f1f77bcf86cd799439012" 273 | } 274 | }, 275 | "required": ["titulo", "descricao", "contato", "empresa"] 276 | } 277 | } 278 | } 279 | }, 280 | "responses": { 281 | "201": { "description": "Reclamação criada com sucesso" }, 282 | "400": { "description": "Dados inválidos" }, 283 | "401": { "description": "Não autenticado" } 284 | } 285 | } 286 | }, 287 | "/reclamacoes/minhas": { 288 | "get": { 289 | "tags": ["Ciclo de Reclamação"], 290 | "summary": "Listar minhas reclamações", 291 | "description": "Usuário autenticado pode listar todas as reclamações que ele fez.", 292 | "responses": { 293 | "200": { 294 | "description": "Lista de reclamações do usuário", 295 | "content": { 296 | "application/json": { 297 | "schema": { 298 | "type": "array", 299 | "items": { "$ref": "#/components/schemas/Reclamacao" } 300 | } 301 | } 302 | } 303 | }, 304 | "401": { "description": "Não autenticado" } 305 | } 306 | } 307 | }, 308 | "/reclamacoes/recebidas": { 309 | "get": { 310 | "tags": ["Ciclo de Reclamação"], 311 | "summary": "Listar reclamações recebidas pela empresa", 312 | "description": "Empresa autenticada pode listar todas as reclamações que recebeu.", 313 | "responses": { 314 | "200": { 315 | "description": "Lista de reclamações recebidas", 316 | "content": { 317 | "application/json": { 318 | "schema": { 319 | "type": "array", 320 | "items": { "$ref": "#/components/schemas/Reclamacao" } 321 | } 322 | } 323 | } 324 | }, 325 | "401": { "description": "Não autenticado" } 326 | } 327 | } 328 | }, 329 | "/reclamacoes/{id}": { 330 | "get": { 331 | "tags": ["Ciclo de Reclamação"], 332 | "summary": "Buscar reclamação por ID", 333 | "description": "Retorna os detalhes de uma reclamação específica.", 334 | "parameters": [ 335 | { 336 | "name": "id", 337 | "in": "path", 338 | "required": true, 339 | "description": "ID da reclamação", 340 | "schema": { 341 | "type": "string", 342 | "example": "507f1f77bcf86cd799439013" 343 | } 344 | } 345 | ], 346 | "responses": { 347 | "200": { 348 | "description": "Detalhes da reclamação", 349 | "content": { 350 | "application/json": { 351 | "schema": { "$ref": "#/components/schemas/Reclamacao" } 352 | } 353 | } 354 | }, 355 | "404": { "description": "Reclamação não encontrada" } 356 | } 357 | }, 358 | "delete": { 359 | "tags": ["Ciclo de Reclamação"], 360 | "summary": "Deletar uma reclamação", 361 | "description": "Permite que o autor ou admin exclua uma reclamação.", 362 | "parameters": [ 363 | { 364 | "name": "id", 365 | "in": "path", 366 | "required": true, 367 | "description": "ID da reclamação", 368 | "schema": { 369 | "type": "string", 370 | "example": "507f1f77bcf86cd799439013" 371 | } 372 | } 373 | ], 374 | "responses": { 375 | "200": { "description": "Reclamação deletada com sucesso" }, 376 | "401": { "description": "Não autenticado" }, 377 | "403": { "description": "Sem permissão" }, 378 | "404": { "description": "Reclamação não encontrada" } 379 | } 380 | } 381 | }, 382 | "/reclamacoes/{id}/responder": { 383 | "patch": { 384 | "tags": ["Ciclo de Reclamação"], 385 | "summary": "Responder uma reclamação", 386 | "description": "Empresa autenticada pode responder uma reclamação aberta.", 387 | "parameters": [ 388 | { 389 | "name": "id", 390 | "in": "path", 391 | "required": true, 392 | "description": "ID da reclamação", 393 | "schema": { 394 | "type": "string", 395 | "example": "507f1f77bcf86cd799439013" 396 | } 397 | } 398 | ], 399 | "requestBody": { 400 | "required": true, 401 | "content": { 402 | "application/json": { 403 | "schema": { 404 | "type": "object", 405 | "properties": { 406 | "resposta": { 407 | "type": "string", 408 | "example": "Agradecemos o contato, vamos resolver seu problema." 409 | } 410 | }, 411 | "required": ["resposta"] 412 | } 413 | } 414 | } 415 | }, 416 | "responses": { 417 | "200": { "description": "Reclamação respondida com sucesso" }, 418 | "400": { "description": "Dados inválidos" }, 419 | "401": { "description": "Não autenticado" }, 420 | "403": { "description": "Sem permissão" }, 421 | "404": { "description": "Reclamação não encontrada" } 422 | } 423 | } 424 | }, 425 | "/reclamacoes/{id}/avaliar": { 426 | "post": { 427 | "tags": ["Ciclo de Reclamação"], 428 | "summary": "Avaliar atendimento da reclamação", 429 | "description": "Usuário pode avaliar o atendimento após resposta da empresa.", 430 | "parameters": [ 431 | { 432 | "name": "id", 433 | "in": "path", 434 | "required": true, 435 | "description": "ID da reclamação", 436 | "schema": { 437 | "type": "string", 438 | "example": "507f1f77bcf86cd799439013" 439 | } 440 | } 441 | ], 442 | "requestBody": { 443 | "required": true, 444 | "content": { 445 | "application/json": { 446 | "schema": { 447 | "type": "object", 448 | "properties": { 449 | "estrelas": { 450 | "type": "integer", 451 | "minimum": 1, 452 | "maximum": 5, 453 | "example": 4 454 | }, 455 | "comentario": { 456 | "type": "string", 457 | "example": "Empresa resolveu meu problema rapidamente." 458 | } 459 | }, 460 | "required": ["estrelas"] 461 | } 462 | } 463 | } 464 | }, 465 | "responses": { 466 | "200": { "description": "Avaliação registrada com sucesso" }, 467 | "400": { "description": "Dados inválidos" }, 468 | "401": { "description": "Não autenticado" }, 469 | "403": { "description": "Sem permissão" }, 470 | "404": { "description": "Reclamação não encontrada" } 471 | } 472 | } 473 | } 474 | }, 475 | "components": { 476 | "schemas": { 477 | "Reclamacao": { 478 | "type": "object", 479 | "properties": { 480 | "_id": { "type": "string", "example": "507f1f77bcf86cd799439013" }, 481 | "titulo": { "type": "string", "example": "Atendimento ruim" }, 482 | "descricao": { 483 | "type": "string", 484 | "example": "Fui mal atendido na loja X" 485 | }, 486 | "contato": { "type": "string", "example": "(11) 99999-9999" }, 487 | "status": { "type": "string", "example": "aberta" }, 488 | "resposta": { 489 | "type": "string", 490 | "example": "Agradecemos o contato, vamos resolver seu problema." 491 | }, 492 | "avaliacao": { 493 | "type": "object", 494 | "properties": { 495 | "estrelas": { "type": "integer", "example": 4 }, 496 | "comentario": { 497 | "type": "string", 498 | "example": "Empresa resolveu meu problema rapidamente." 499 | } 500 | } 501 | }, 502 | "empresa": { 503 | "type": "object", 504 | "properties": { 505 | "_id": { 506 | "type": "string", 507 | "example": "507f1f77bcf86cd799439012" 508 | }, 509 | "nome": { "type": "string", "example": "Empresa X" } 510 | } 511 | }, 512 | "user": { 513 | "type": "object", 514 | "properties": { 515 | "_id": { 516 | "type": "string", 517 | "example": "507f1f77bcf86cd799439011" 518 | }, 519 | "nome": { "type": "string", "example": "João Silva" } 520 | } 521 | }, 522 | "createdAt": { 523 | "type": "string", 524 | "example": "2023-01-15T10:30:00.000Z" 525 | }, 526 | "updatedAt": { 527 | "type": "string", 528 | "example": "2023-01-15T10:30:00.000Z" 529 | } 530 | } 531 | } 532 | } 533 | } 534 | } 535 | --------------------------------------------------------------------------------