├── 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 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
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 |
44 |
45 |
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 |
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 |
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 |
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
- Clone este repositório:
git clone https://github.com/mmyersbyte/appdesatende
- Acesse a pasta do backend:
cd backend
- 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. - Instale as dependências:
npm install
- Inicie o servidor:
npm run dev
ou node server.js
72 |
73 | Como rodar o frontend localmente
- Pré-requisitos: Tenha o Node.js e o Expo CLI instalados em seu computador.
- Acesse a pasta do frontend:
cd app
- Instale as dependências:
npm install
- 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. - Execute o projeto:
npx expo start
- Testando no seu celular físico:
Instale o aplicativo Expo Go na Play Store/App Store, escaneie o QR Code do terminal e pronto! - 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 |
--------------------------------------------------------------------------------