diff --git a/README.md b/README.md index 0c45ff3..80b1261 100644 --- a/README.md +++ b/README.md @@ -1,318 +1,189 @@ -# IworkG — Marketplace de Serviços - -[![License: MIT](https://img.shields.io/badge/License-MIT-green.svg)](https://opensource.org/licenses/MIT) -[![Sprint 2](https://img.shields.io/badge/Sprint-2-blue)]() - -Conecta clientes a prestadores de serviços de construção e manutenção (eletricistas, encanadores, pedreiros, etc.) com busca georreferenciada e portfólio visual "Antes e Depois". - -## Stack - -| Camada | Tecnologia | -|----------|-------------------------------------| -| Backend | Node.js + Express + TypeScript | -| Frontend | React + Vite + TypeScript | -| Banco | SQLite (better-sqlite3) | -| Auth | Google OAuth 2.0 + SMS OTP | -| Mapa | Geolocation API + ViaCEP + Haversine| -| Testes | Vitest + Supertest | - -## Arquitetura - -```mermaid -graph TD - subgraph Frontend ["Frontend (React + Vite)"] - Pages["Páginas: Login, Busca, Perfil, Mural, etc."] - Components["Componentes: ProtectedRoute, Toast, StarRating, etc."] - Services["Services: api.ts + auth, search, requests, etc."] - Context["AuthContext (JWT + refresh)"] - end - - subgraph Backend ["Backend (Express + SQLite)"] - Routes["Rotas: auth, search, providers, requests, etc."] - Middleware["Middleware: requireAuth, requireRole, rateLimit"] - Services["Services: OTP, Token, Image"] - DB["SQLite (better-sqlite3)"] - end - - Frontend -->|REST JSON| Backend - Backend --> DB -``` +# IworkG — Marketplace de Serviços Locais -## Estrutura +Conecta clientes a prestadores de serviços (eletricistas, encanadores, pedreiros, pintores, etc.) com busca georreferenciada, portfólio visual e sistema de pedidos. -``` -IworkG/ -├── backend/ -│ ├── src/ -│ │ ├── config.ts # Variáveis de ambiente -│ │ ├── db.ts # Schema + seed + queries -│ │ ├── server.ts # Servidor Express -│ │ ├── types.ts # Tipos TypeScript -│ │ ├── middleware/ -│ │ │ ├── auth.ts # requireAuth + requireRole -│ │ │ └── rateLimit.ts # Rate limiting -│ │ ├── routes/ -│ │ │ ├── auth.ts # Google OAuth, OTP, refresh, logout -│ │ │ ├── search.ts # Categorias + busca prestadores -│ │ │ ├── provider.ts # Wizard, perfil próprio, portfólio -│ │ │ ├── providers.ts # Perfil público, edição, reviews, reports -│ │ │ ├── requests.ts # CRUD pedidos, interesse, mural -│ │ │ ├── contacts.ts # Histórico de contatos -│ │ │ ├── favorites.ts # Favoritos (toggle) -│ │ │ ├── notifications.ts # Notificações + preferências -│ │ │ └── admin.ts # Painel admin (dashboard, categorias) -│ │ ├── services/ -│ │ │ ├── otp.ts # Geração/verificação OTP -│ │ │ ├── token.ts # JWT access + refresh tokens -│ │ │ └── image.ts # Processamento de imagem (Jimp) -│ │ └── __tests__/ # Testes de integração -│ │ └── integration.test.ts -│ ├── data/ # Banco SQLite (gitignored) -│ ├── uploads/ # Imagens de portfólio (gitignored) -│ ├── .env.example -│ └── package.json -├── frontend/ -│ ├── src/ -│ │ ├── App.tsx # Rotas -│ │ ├── index.css # Estilos globais -│ │ ├── styles/tokens.css # Design tokens -│ │ ├── contexts/ -│ │ │ └── AuthContext.tsx # Estado global de auth -│ │ ├── components/ -│ │ │ ├── ProtectedRoute.tsx -│ │ │ ├── Button.tsx -│ │ │ ├── Card.tsx -│ │ │ ├── Chip.tsx -│ │ │ ├── Modal.tsx -│ │ │ ├── Toast.tsx -│ │ │ ├── StarRating.tsx -│ │ │ ├── InteractiveStars.tsx -│ │ │ ├── PortfolioGallery.tsx -│ │ │ ├── ReviewSection.tsx -│ │ │ ├── ReportModal.tsx -│ │ │ ├── ContactModal.tsx -│ │ │ ├── ProgressBar.tsx -│ │ │ └── RaioSlider.tsx -│ │ ├── pages/ -│ │ │ ├── LoginPage.tsx -│ │ │ ├── OTPPage.tsx -│ │ │ ├── AuthCallbackPage.tsx -│ │ │ ├── ForgotAccessPage.tsx -│ │ │ ├── DashboardPage.tsx -│ │ │ ├── SearchPage.tsx -│ │ │ ├── RequestDetailPage.tsx -│ │ │ ├── NewRequestPage.tsx -│ │ │ ├── MyRequestsPage.tsx -│ │ │ ├── RequestBoardPage.tsx -│ │ │ ├── ProviderProfilePage.tsx -│ │ │ ├── ProviderEditPage.tsx -│ │ │ ├── MyProviderPage.tsx -│ │ │ ├── ProviderRegisterPage.tsx -│ │ │ ├── ContactsPage.tsx -│ │ │ ├── FavoritesPage.tsx -│ │ │ ├── NotificationsPage.tsx -│ │ │ ├── NotificationPreferencesPage.tsx -│ │ │ ├── AdminDashboardPage.tsx -│ │ │ ├── AdminCategoriesPage.tsx -│ │ │ ├── AdminPage.tsx -│ │ │ ├── DesignPage.tsx -│ │ │ ├── HelpPage.tsx -│ │ │ ├── TermsPage.tsx -│ │ │ └── PrivacyPage.tsx -│ │ └── services/ -│ │ ├── api.ts # Axios + interceptors -│ │ ├── auth.ts # Auth API -│ │ ├── search.ts # Search API -│ │ ├── provider.ts # Provider wizard API -│ │ ├── providers.ts # Provider profile API -│ │ ├── requests.ts # Requests API -│ │ ├── history.ts # Contacts/Favorites API -│ │ ├── notifications.ts # Notifications API -│ │ ├── admin.ts # Admin API -│ │ └── location.ts # Geolocation helpers -│ ├── .env.example -│ └── package.json -├── .github/workflows/ -│ └── test.yml # CI pipeline -├── package.json -├── .gitignore -└── README.md -``` +--- -## Como rodar +## Stack Tecnológico -### Pré-requisitos +| Camada | Tecnologia | +|---|---| +| Backend | Node.js + Express 4 + TypeScript 5 | +| Frontend | React 19 + Vite 8 + TypeScript | +| Banco de Dados | SQLite (better-sqlite3) | +| Autenticação | Google OAuth 2.0 + SMS OTP (código via terminal em dev) | +| Testes | Vitest + Supertest (72 testes) | +| CI/CD | GitHub Actions (typecheck + testes + build) | +| Docker | docker-compose com nginx + Node | + +--- +## Como Rodar + +### Pré-requisitos - Node.js ≥ 18 - npm ≥ 9 -### Instalação +### Passo 1 — Clonar e instalar ```bash +git clone https://github.com/GrouwBer/IworkG.git +cd IworkG npm run install:all ``` -### Desenvolvimento - -```bash -npm run dev -``` - -Inicia ambos servidores simultaneamente: -- **Backend:** http://localhost:3001 -- **Frontend:** http://localhost:5173 - -### Variáveis de ambiente +### Passo 2 — Configurar .env ```bash cp backend/.env.example backend/.env cp frontend/.env.example frontend/.env ``` -Configure no `backend/.env`: -- `GOOGLE_CLIENT_ID` — ID do app Google Cloud Console -- `GOOGLE_CLIENT_SECRET` — Secret do app Google -- `JWT_SECRET` — Chave para assinar tokens JWT -- `FRONTEND_URL` — URL do frontend (default: http://localhost:5173) +Edite `backend/.env` com suas credenciais: +``` +PORT=3001 +JWT_SECRET=sua-chave-secreta +GOOGLE_CLIENT_ID=seu-client-id.apps.googleusercontent.com +GOOGLE_CLIENT_SECRET=seu-client-secret +FRONTEND_URL=http://localhost:5173 +``` + +Edite `frontend/.env`: +``` +VITE_API_URL=http://localhost:3001 +VITE_GOOGLE_CLIENT_ID=mesmo-client-id-acima +``` -### Build +### Passo 3 — Rodar ```bash -cd backend && npm run build -cd ../frontend && npm run build +# Terminal 1 — Backend (porta 3001) +cd backend && npm run dev + +# Terminal 2 — Frontend (porta 5173) +cd frontend && npm run dev ``` -### Testes +Acesse **http://localhost:5173** + +### Opção Docker ```bash -cd backend && npm test +docker compose up -d --build ``` +Acesse **http://localhost** (tudo num container só). -## Documento de API +--- -### Autenticação (`/api/auth`) +## Como Usar -| Método | Rota | Auth | Descrição | -|--------|------|------|-----------| -| POST | `/google` | Não | Login com Google ID token | -| GET | `/google/url` | Não | URL de autorização Google | -| GET | `/google/callback` | Não | Callback OAuth | -| POST | `/otp/send` | Não | Enviar código OTP | -| POST | `/otp/verify` | Não | Verificar OTP e obter tokens | -| POST | `/refresh` | Não | Renovar access token | -| POST | `/logout` | Sim | Invalidar token | -| GET | `/me` | Sim | Dados do usuário logado | -| POST | `/recover/send` | Não | Enviar código recuperação | -| POST | `/recover/verify` | Não | Verificar código recuperação | -| POST | `/recover/reset` | Não | Redefinir método de acesso | -| DELETE | `/account` | Sim | Excluir/anonymizar conta | +### Login +- **Google** — clique em "Entrar com Google" +- **Telefone** — digite o número, o código aparece no terminal do backend -### Busca (`/api`) +### Cliente +1. Faça login → Dashboard +2. Clique em **Buscar Prestadores** — pesquise por nome ou categoria +3. Clique no perfil de um prestador → veja avaliações e portfólio +4. Clique em **Publicar Pedido** — descreva o serviço, defina valor máximo (opcional) +5. Acompanhe em **Meus Pedidos** — veja quem demonstrou interesse +6. Entre em contato com o prestador escolhido -| Método | Rota | Auth | Descrição | -|--------|------|------|-----------| -| GET | `/categories` | Não | Listar categorias | -| GET | `/providers/search?lat=&lng=&radius_km=&category_id=&query=` | Não | Buscar prestadores | +### Prestador +1. Dashboard → **Tornar-se Prestador** — preencha o wizard de 6 passos +2. Após cadastro, acesse o **Mural de Pedidos** para ver demandas de clientes +3. Clique em **Demonstrar Interesse** nos pedidos que quiser pegar +4. Também pode **criar seus próprios pedidos** como cliente +5. Gerencie seu perfil em **Meu Perfil** → **Editar Perfil** -### Provider Wizard (`/api/provider`) — requer auth +### Editar Pedidos +- Em **Meus Pedidos**, pedidos abertos têm botão **✏️ Editar** +- Altere título, descrição e valor máximo +- Ou **Cancelar** se não precisar mais -| Método | Rota | Descrição | -|--------|------|-----------| -| GET | `/wizard` | Estado do wizard | -| PUT | `/wizard` | Salvar progresso | -| POST | `/wizard/complete` | Finalizar cadastro | -| GET | `/me` | Perfil próprio com serviceRadiusKm | -| POST | `/portfolio/upload` | Upload de foto (multipart) | -| GET | `/me/portfolio` | Portfólio próprio | -| GET | `/:id/portfolio` | Portfólio público | - -### Provider Profiles (`/api/providers`) - -| Método | Rota | Auth | Descrição | -|--------|------|------|-----------| -| GET | `/:id` | Não | Perfil público do prestador | -| GET | `/profile/mine` | Sim | Perfil próprio para edição | -| PUT | `/profile` | Sim | Atualizar perfil | -| POST | `/portfolio` | Sim | Adicionar imagem (URL) | -| DELETE | `/portfolio/:id` | Sim | Remover imagem | -| GET | `/:userId/reviews` | Não | Listar avaliações | -| POST | `/:userId/reviews` | Sim | Criar avaliação | -| POST | `/:userId/report` | Sim | Denunciar prestador | -| GET | `/admin/reports` | Admin | Listar denúncias pendentes | - -### Requests (`/api/requests`) — requer auth - -> ⚠️ Rotas disponíveis a partir do PR #51 (Mural de Pedidos) +--- -| Método | Rota | Descrição | -|--------|------|-----------| -| GET | `/open` | Buscar pedidos abertos (mural) | -| POST | `/` | Publicar novo pedido | -| GET | `/mine` | Meus pedidos (cliente) | -| GET | `/:id` | Detalhes do pedido | -| POST | `/:id/interest` | Demonstrar interesse (prestador) | -| GET | `/:id/interests` | Ver interessados | +## Estrutura do Projeto -### Contacts (`/api/contacts`) — requer auth +``` +IworkG/ +├── backend/ +│ ├── src/ +│ │ ├── config.ts # Variáveis de ambiente +│ │ ├── db.ts # Schema + seed + queries +│ │ ├── server.ts # Servidor Express + CORS +│ │ ├── types.ts # Tipos TypeScript +│ │ ├── middleware/ +│ │ │ ├── auth.ts # requireAuth + requireRole +│ │ │ └── rateLimit.ts # Rate limiting +│ │ ├── routes/ +│ │ │ ├── auth.ts # Google OAuth, OTP, refresh, logout +│ │ │ ├── search.ts # Categorias + busca prestadores +│ │ │ ├── provider.ts # Wizard, perfil próprio, portfólio +│ │ │ ├── providers.ts # Perfil público, edição, reviews, reports +│ │ │ ├── requests.ts # CRUD pedidos, interesse, mural +│ │ │ ├── contacts.ts # Histórico de contatos +│ │ │ ├── favorites.ts # Favoritos (toggle) +│ │ │ ├── notifications.ts # Notificações + preferências +│ │ │ └── admin.ts # Painel admin (dashboard, categorias) +│ │ ├── services/ # OTP, Token, Image, Notifications +│ │ └── __tests__/ # 72 testes de integração +│ ├── data/ # Banco SQLite (gitignored) +│ └── uploads/ # Imagens de portfólio +├── frontend/ +│ ├── src/ +│ │ ├── components/ # Header, ProtectedRoute, Toast, Modal, etc. +│ │ ├── contexts/ # AuthContext (JWT + refresh) +│ │ ├── pages/ # 20+ páginas (Login, Busca, Perfil, etc.) +│ │ └── services/ # API client + serviços +│ └── nginx.conf # Config nginx para Docker +├── docker-compose.yml +├── .github/workflows/test.yml # CI pipeline +└── README.md +``` -| Método | Rota | Descrição | -|--------|------|-----------| -| GET | `/` | Histórico de contatos | -| POST | `/` | Registrar novo contato | +--- -### Favorites (`/api/favorites`) — requer auth +## API — Principais Rotas | Método | Rota | Descrição | -|--------|------|-----------| -| GET | `/` | Listar favoritos | -| POST | `/:providerId` | Toggle favorito | -| GET | `/check/:providerId` | Verificar se favoritado | +|---|---|---| +| POST | `/api/auth/google` | Login com Google | +| POST | `/api/auth/otp/send` | Enviar código OTP | +| POST | `/api/auth/otp/verify` | Verificar OTP | +| GET | `/api/categories` | Listar categorias | +| GET | `/api/providers/search?query=&category_id=` | Buscar prestadores | +| GET | `/api/providers/:userId` | Perfil público do prestador | +| GET | `/api/provider/wizard` | Estado do wizard de cadastro | +| PUT | `/api/provider/wizard` | Salvar progresso do wizard | +| POST | `/api/provider/wizard/complete` | Finalizar cadastro | +| GET | `/api/requests/mine` | Meus pedidos | +| POST | `/api/requests` | Criar pedido | +| PATCH | `/api/requests/:id` | Editar/cancelar pedido | +| GET | `/api/requests/open` | Mural de pedidos (prestador) | +| POST | `/api/requests/:id/interest` | Demonstrar interesse | +| POST | `/api/favorites/:userId` | Toggle favorito | +| GET | `/api/notifications` | Listar notificações | + +--- + +## Testes -### Notifications (`/api/notifications`) — requer auth +```bash +cd backend && npm test # 72 testes automatizados +``` -> ⚠️ Rotas de preferências (`/preferences`) disponíveis a partir do PR #55 (Notificações Push) +--- -| Método | Rota | Descrição | -|--------|------|-----------| -| GET | `/` | Listar notificações + unreadCount | -| PATCH | `/:id/read` | Marcar como lida | -| PATCH | `/read-all` | Marcar todas como lidas | -| GET | `/preferences` | Preferências de notificação | -| PUT | `/preferences` | Atualizar preferências | +## CI/CD -### Admin (`/api/admin`) — requer role admin +Toda pull request ou push em `main`/`dev` dispara: +- ✅ TypeScript typecheck (backend + frontend) +- ✅ 72 testes automatizados (Vitest) +- ✅ Build de produção (Vite) +- ✅ Build Docker -| Método | Rota | Descrição | -|--------|------|-----------| -| GET | `/stats?days=30` | Dashboard analytics | -| GET | `/categories` | Categorias com contagem | -| POST | `/categories` | Criar categoria | -| PUT | `/categories/:id` | Editar categoria | -| DELETE | `/categories/:id` | Soft/hard delete categoria | - -## Fluxos Principais - -### Cliente — Busca e Contratação -1. Login (Google ou OTP) → `/buscar` -2. Busca por categoria + geolocalização automática -3. Visualiza perfil do prestador (avaliações, portfólio) -4. Publica pedido → `/publicar` -5. Recebe interesses → notificação -6. Visualiza interessados → contato - -### Prestador — Cadastro e Serviço -1. Login → Dashboard -2. "Tornar-se Prestador" → Wizard 5 etapas -3. Upload de portfólio "Antes e Depois" -4. Toggle disponível/ocupado + raio de atuação -5. Mural de pedidos → demonstrar interesse -6. Recebe avaliações → notificação - -### Admin — Gestão -1. Login como admin → `/admin/dashboard` -2. Dashboard com métricas (período selecionável) -3. CRUD de categorias -4. Revisão de denúncias +--- ## Licença diff --git a/backend/package-lock.json b/backend/package-lock.json index 6f741d4..798dea8 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -16,6 +16,7 @@ "jimp": "^1.6.0", "jsonwebtoken": "^9.0.2", "multer": "^1.4.5-lts.2", + "sql.js": "^1.14.1", "uuid": "^10.0.0" }, "devDependencies": { @@ -43,29 +44,6 @@ "url": "https://github.com/sponsors/Borewit" } }, - "node_modules/@emnapi/core": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", - "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/wasi-threads": "1.2.2", - "tslib": "^2.4.0" - } - }, - "node_modules/@emnapi/runtime": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", - "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, "node_modules/@emnapi/wasi-threads": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", @@ -3620,6 +3598,7 @@ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -4085,6 +4064,12 @@ "node": ">=0.10.0" } }, + "node_modules/sql.js": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/sql.js/-/sql.js-1.14.1.tgz", + "integrity": "sha512-gcj8zBWU5cFsi9WUP+4bFNXAyF1iRpA3LLyS/DP5xlrNzGmPIizUeBggKa8DbDwdqaKwUcTEnChtd2grWo/x/A==", + "license": "MIT" + }, "node_modules/stackback": { "version": "0.0.2", "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", @@ -4359,6 +4344,7 @@ "integrity": "sha512-X8EX+XV4QR5xCsrgxaED954zTDfY8KqlDtskKEL0cHhyS/P8b4IFOvGDQpsC9Q1XnLq915wEfwwY/zzskCtmhg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "~0.28.0" }, @@ -4498,6 +4484,7 @@ "integrity": "sha512-BuJcQK/56NQTWDGn4ABea3q4SSBdNPWwNZKTkkUpcMPnLoquSYH8llRtSUIgoL1KSCpHt5eghLShn50mH36y7Q==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", diff --git a/backend/package.json b/backend/package.json index a48d568..b4ef08c 100644 --- a/backend/package.json +++ b/backend/package.json @@ -17,6 +17,7 @@ "jimp": "^1.6.0", "jsonwebtoken": "^9.0.2", "multer": "^1.4.5-lts.2", + "sql.js": "^1.14.1", "uuid": "^10.0.0" }, "devDependencies": { diff --git a/backend/seed-clients-reviews.js b/backend/seed-clients-reviews.js new file mode 100644 index 0000000..bfdaa68 --- /dev/null +++ b/backend/seed-clients-reviews.js @@ -0,0 +1,86 @@ +const Database = require('better-sqlite3'); +const path = require('path'); +const { v4: uuidv4 } = require('uuid'); + +const DB_PATH = path.join(__dirname, 'data', 'iworkg.db'); +const db = new Database(DB_PATH); +db.pragma('foreign_keys = ON'); + +// ── 10 novos clientes (só clientes, não prestadores) ── +const clients = [ + { name: 'Marina Telles', phone: '5535988771122' }, + { name: 'Augusto Cesar', phone: '5511988663344' }, + { name: 'Helena Dutra', phone: '5521988556677' }, + { name: 'Nelson Freitas', phone: '5531988445566' }, + { name: 'Cintia Lacerda', phone: '5535988334455' }, + { name: 'Otávio Rangel', phone: '5519988223344' }, + { name: 'Renata Borges', phone: '5521988112233' }, + { name: 'Fabrício Neves', phone: '5531988001122' }, + { name: 'Tatiane Moura', phone: '5535988990011' }, + { name: 'Valdir Coelho', phone: '5511988779900' }, +]; + +const categories = db.prepare("SELECT id, name FROM categories LIMIT 12").all(); +const providers = db.prepare("SELECT u.id, u.name, pp.id as profile_id FROM users u JOIN provider_profiles pp ON pp.user_id = u.id ORDER BY RANDOM()").all(); + +const baseLat = -21.7884; +const baseLng = -46.5616; + +const requestTitles = [ + ['Troca de resistência de chuveiro', 'Chuveiro Lorenzetti parou de esquentar. Preciso trocar a resistência.'], + ['Instalação de ventilador de teto', 'Comprei 2 ventiladores e preciso instalar na sala e no quarto.'], + ['Reparo de tomada queimada', 'Uma tomada da cozinha soltou faísca e parou de funcionar.'], + ['Entupimento de pia', 'Pia da cozinha entupida, água não desce. Preciso urgente.'], + ['Reboco de parede', 'Parede do quintal descascando, aproximadamente 6m².'], + ['Pintura de portão', 'Portão de ferro enferrujando, preciso lixar e pintar.'], + ['Montagem de estante', 'Estante de 8 prateleiras nova na caixa. Preciso montar.'], + ['Gramado para plantar', 'Preparar terreno de 30m² para plantar grama esmeralda.'], + ['Limpeza de caixa dágua', 'Caixa dágua de 1000L precisa de limpeza.'], + ['Chave quebrada na fechadura', 'A chave quebrou dentro da fechadura do portão. Preciso extrair e trocar.'], +]; + +const reviewTexts = [ + 'Excelente profissional! Chegou no horário, service rápido e bem feito. Super recomendo.', + 'Muito bom! Resolveu o problema que outros não conseguiram. Preço justo.', + 'Profissional muito competente e educado. Deixou tudo limpo depois do service. Nota 10!', + 'Fez um ótimo trabalho, mas atrasou 30 minutos. No geral, recomendo.', + 'Atendimento excelente, resolveu rapidinho. Voltarei a chamar quando precisar.', + 'Profissional de confiança, explicou tudo que ia fazer. Muito satisfeita com o resultado.', + 'Preço um pouco acima da média, mas o service foi impecável. Valeu cada centavo.', + 'Muito atencioso e caprichoso. O service ficou melhor do que eu esperava.', + 'Pontual e eficiente. Resolveu em 1h o que eu achava que levaria o dia todo.', + 'Trabalho bem feito, material de qualidade. Já indiquei pra vizinhança.', +]; + +const insertUser = db.prepare('INSERT INTO users (id, name, phone, role) VALUES (?, ?, ?, ?)'); +const insertRequest = db.prepare(`INSERT INTO service_requests (id, client_id, title, description, category_id, urgency, latitude, longitude, city, state, address, budget, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now', ?))`); +const insertReview = db.prepare('INSERT INTO reviews (id, provider_id, client_id, rating, comment) VALUES (?, ?, ?, ?, ?)'); + +const tx = db.transaction(() => { + for (let i = 0; i < 10; i++) { + const c = clients[i]; + const userId = uuidv4(); + insertUser.run(userId, c.name, c.phone, 'client'); + + // Create a service request + const cat = categories[Math.floor(Math.random() * categories.length)]; + const urgency = ['Alta', 'Média', 'Baixa'][Math.floor(Math.random() * 3)]; + const lat = baseLat + (Math.random() - 0.5) * 0.03; + const lng = baseLng + (Math.random() - 0.5) * 0.03; + const budget = Math.random() > 0.4 ? Math.round(Math.random() * 500 + 50) : null; + const [title, desc] = requestTitles[i]; + const offset = `-${Math.floor(Math.random() * 72)} hours`; + + const requestId = uuidv4(); + insertRequest.run(requestId, userId, title, desc, cat.id, urgency, lat, lng, 'Poços de Caldas', 'MG', `Rua Exemplo, ${100 + i * 10}`, budget, offset); + + // Leave a review for a random provider + const provider = providers[Math.floor(Math.random() * providers.length)]; + const rating = Math.floor(Math.random() * 2) + 4; // 4 or 5 + insertReview.run(uuidv4(), provider.profile_id, userId, rating, reviewTexts[i]); + } +}); + +tx(); +console.log('✅ +10 clientes, +10 pedidos, +10 avaliações criados!'); +db.close(); diff --git a/backend/seed-more-requests.js b/backend/seed-more-requests.js new file mode 100644 index 0000000..ecd47c2 --- /dev/null +++ b/backend/seed-more-requests.js @@ -0,0 +1,59 @@ +const Database = require('better-sqlite3'); +const path = require('path'); +const { v4: uuidv4 } = require('uuid'); + +const DB_PATH = path.join(__dirname, 'data', 'iworkg.db'); +const db = new Database(DB_PATH); + +// Create 5 new client users so requests come from different people +const newClients = [ + { name: 'Roberto Alves', phone: '5535999887766', email: 'roberto@email.com' }, + { name: 'Fernanda Gomes', phone: '5519988776655', email: 'fernanda@email.com' }, + { name: 'Cláudio Mendes', phone: '5511977665544', email: 'claudio@email.com' }, + { name: 'Patrícia Santos', phone: '5521999887744', email: 'patricia@email.com' }, + { name: 'Luiz Fernando', phone: '5531998776622', email: 'luiz@email.com' }, +]; + +const categories = db.prepare("SELECT id, name FROM categories LIMIT 12").all(); +const baseLat = -21.7884; +const baseLng = -46.5616; + +const insertUser = db.prepare('INSERT INTO users (id, name, phone, email, role) VALUES (?, ?, ?, ?, ?)'); +const insertRequest = db.prepare(`INSERT INTO service_requests (id, client_id, title, description, category_id, urgency, latitude, longitude, city, state, address, budget, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now', ?))`); + +const requests = [ + 'Preciso de eletricista para instalar lustre', 'Lustre de 8 lâmpadas, pé direito alto. Preciso de profissional com escada.', + 'Vazamento no banheiro', 'Água infiltrando no rejunte do box. Preciso de avaliação e reparo.', + 'Pintura de fachada', 'Fachada de casa de 2 andares, aproximadamente 80m². Tinta já comprada.', + 'Jardim abandonado', 'Jardim de 50m² tomado por mato. Preciso de limpeza e replantio.', + 'Montagem de cozinha planejada', 'Cozinha Itatiaia 12 módulos. Preciso de montador com experiência.', +]; + +const tx = db.transaction(() => { + for (let i = 0; i < newClients.length; i++) { + const c = newClients[i]; + const userId = uuidv4(); + insertUser.run(userId, c.name, c.phone, c.email, 'client'); + + const cat = categories[Math.floor(Math.random() * categories.length)]; + const urgency = ['Alta', 'Média', 'Baixa'][Math.floor(Math.random() * 3)]; + const lat = baseLat + (Math.random() - 0.5) * 0.03; + const lng = baseLng + (Math.random() - 0.5) * 0.03; + const budget = Math.random() > 0.5 ? Math.round(Math.random() * 500 + 50) : null; + + const title = requests[i * 2]; + const desc = requests[i * 2 + 1]; + const offset = `-${Math.floor(Math.random() * 48)} hours`; + + insertRequest.run( + uuidv4(), userId, title, desc, cat.id, urgency, + lat, lng, 'Poços de Caldas', 'MG', + `Rua de Teste, ${Math.floor(Math.random() * 500)}`, + budget, offset + ); + } +}); + +tx(); +console.log('✅ 5 novos clientes + 5 pedidos de outras pessoas criados!'); +db.close(); diff --git a/backend/seed-providers.js b/backend/seed-providers.js new file mode 100644 index 0000000..9d3f0fa --- /dev/null +++ b/backend/seed-providers.js @@ -0,0 +1,65 @@ +const Database = require('better-sqlite3'); +const path = require('path'); +const { v4: uuidv4 } = require('uuid'); + +const DB_PATH = path.join(__dirname, 'data', 'iworkg.db'); +const db = new Database(DB_PATH); +db.pragma('foreign_keys = ON'); + +const providers = [ + { name: 'Ricardo Nunes', category: 'cat-eletricista', description: 'Eletricista especializado em automação residencial. Instalo tomadas inteligentes, sensores e sistemas de iluminação automatizada.', experience: 14, city: 'Poços de Caldas', state: 'MG', lat: -21.792, lng: -46.550, address: 'Rua Paraná, 70' }, + { name: 'Camila Torres', category: 'cat-eletricista', description: 'Eletricista predial. Manutenção preventiva, quadros de distribuição, padrão de entrada e aterramento.', experience: 6, city: 'Andradas', state: 'MG', lat: -22.068, lng: -46.568, address: 'Av. Ricarti Teixeira, 300' }, + { name: 'Thiago Moura', category: 'cat-encanador', description: 'Encanador especialista em sistemas de aquecimento solar. Instalação e manutenção de boilers e placas solares.', experience: 9, city: 'Poços de Caldas', state: 'MG', lat: -21.782, lng: -46.555, address: 'Rua Santa Catarina, 45' }, + { name: 'Patrícia Diniz', category: 'cat-pedreiro', description: 'Pedreira com foco em acabamentos de luxo. Porcelanato, mármore, granito e revestimentos importados.', experience: 18, city: 'Poços de Caldas', state: 'MG', lat: -21.793, lng: -46.562, address: 'Av. Santo Antônio, 1500' }, + { name: 'Gustavo Teixeira', category: 'cat-pedreiro', description: 'Pedreiro de obras rápidas. Pequenas reformas, reparos em muros, calçadas e contrapisos. Dias e sábados.', experience: 7, city: 'Caldas', state: 'MG', lat: -21.924, lng: -46.386, address: 'Rua do Comércio, 88' }, + { name: 'Sandra Vieira', category: 'cat-pintor', description: 'Pintora decorativa. Técnicas de marmorização, textura espanhola e pintura artística em paredes e tetos.', experience: 12, city: 'Poços de Caldas', state: 'MG', lat: -21.788, lng: -46.558, address: 'Rua Goiás, 200' }, + { name: 'Felipe Barros', category: 'cat-marceneiro', description: 'Marceneiro criativo. Móveis rústicos, pallets reaproveitados, estantes personalizadas e brinquedos de madeira.', experience: 5, city: 'Botelhos', state: 'MG', lat: -21.638, lng: -46.393, address: 'Rua Principal, 120' }, + { name: 'Amanda Lopes', category: 'cat-jardineiro', description: 'Paisagista especializada em jardins tropicais. Palmeiras, bromélias, orquídeas e lagos ornamentais com peixes.', experience: 10, city: 'Poços de Caldas', state: 'MG', lat: -21.785, lng: -46.564, address: 'Alameda das Flores, 55' }, + { name: 'Renato Campos', category: 'cat-faxineiro', description: 'Faxineiro especializado em limpeza pós-obra. Remoção de entulho fino, limpeza de vidros e rejunte.', experience: 3, city: 'Poços de Caldas', state: 'MG', lat: -21.790, lng: -46.553, address: 'Rua Piauí, 90' }, + { name: 'Daniela Pires', category: 'cat-mecanico', description: 'Mecânica de motos. Revisão geral, troca de relação, carburador e injeção eletrônica. Busco e entrego.', experience: 8, city: 'Poços de Caldas', state: 'MG', lat: -21.786, lng: -46.566, address: 'Av. João Pinheiro, 780' }, + { name: 'Eduardo Faria', category: 'cat-tecnico-refrigeracao', description: 'Técnico HVAC. Instalação de sistemas de climatização central, splits, multisplits e dutos de ventilação.', experience: 13, city: 'Poços de Caldas', state: 'MG', lat: -21.789, lng: -46.551, address: 'Rua Ceará, 32' }, + { name: 'Vanessa Castro', category: 'cat-chaveiro', description: 'Chaveira automotiva. Codificação de chaves com chip, conserto de fechaduras de carros e abertura de porta.', experience: 6, city: 'Poços de Caldas', state: 'MG', lat: -21.787, lng: -46.557, address: 'Rua Pernambuco, 14' }, + { name: 'Bruno Azevedo', category: 'cat-diarista', description: 'Diarista com referências. Limpeza geral, lavanderia, organização de closets e armários. Material incluso.', experience: 9, city: 'Poços de Caldas', state: 'MG', lat: -21.791, lng: -46.559, address: 'Rua Alagoas, 180' }, + { name: 'Letícia Dantas', category: 'cat-diarista', description: 'Diarista de confiança há 7 anos na mesma família. Cozinho, limpo e cuido de pets durante a faxina.', experience: 7, city: 'Poços de Caldas', state: 'MG', lat: -21.783, lng: -46.563, address: 'Rua Maranhão, 65' }, + { name: 'André Matos', category: 'cat-montador', description: 'Montador de móveis profissionais. Montagem de cozinhas planejadas, racks, painéis de TV e home theater.', experience: 11, city: 'Poços de Caldas', state: 'MG', lat: -21.784, lng: -46.556, address: 'Av. Francisco Salles, 2100' }, + { name: 'Carla Souza', category: 'cat-montador', description: 'Montadora de estruturas metálicas. Telhados, galpões, mezaninos e estruturas para energia solar.', experience: 4, city: 'Andradas', state: 'MG', lat: -22.070, lng: -46.565, address: 'Rua do Rosário, 55' }, + { name: 'Henrique Moraes', category: 'cat-eletricista', description: 'Eletricista de emergência 24h. Curto-circuito, queda de energia parcial, disjuntor desarmando. Chego em 30min.', experience: 16, city: 'Poços de Caldas', state: 'MG', lat: -21.790, lng: -46.560, address: 'Rua Sergipe, 22' }, + { name: 'Tatiana Reis', category: 'cat-pintor', description: 'Pintora de fachadas e áreas externas. Trabalho com tinta elastomérica, hidrorepelente e grafite artístico.', experience: 8, city: 'Poços de Caldas', state: 'MG', lat: -21.788, lng: -46.565, address: 'Rua Tocantins, 40' }, + { name: 'Marcelo Brito', category: 'cat-encanador', description: 'Encanador de emergência. Vazamentos, entupimentos, troca de registros e instalação de bombas dagua.', experience: 20, city: 'Poços de Caldas', state: 'MG', lat: -21.785, lng: -46.552, address: 'Rua Amapá, 15' }, + { name: 'Gabriela Xavier', category: 'cat-mecanico', description: 'Mecânica de carros importados. Especialidade em BMW, Audi e Mercedes. Scanner automotivo e reprogramação.', experience: 10, city: 'Poços de Caldas', state: 'MG', lat: -21.792, lng: -46.564, address: 'Av. Mansur Frayha, 3200' }, +]; + +function generatePhone() { + const ddds = ['35', '19', '11', '21', '31']; + const ddd = ddds[Math.floor(Math.random() * ddds.length)]; + const num = String(Math.floor(Math.random() * 900000000 + 100000000)); + return `55${ddd}${num}`; +} + +const insertUser = db.prepare('INSERT INTO users (id, name, phone, role) VALUES (?, ?, ?, ?)'); +const insertProfile = db.prepare('INSERT INTO provider_profiles (id, user_id, category_id, description, rating, review_count, experience_years, service_radius_km, address, latitude, longitude, city, state, active) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1)'); + +const tx = db.transaction(() => { + for (const p of providers) { + const userId = uuidv4(); + const phone = generatePhone(); + const profileId = uuidv4(); + insertUser.run(userId, p.name, phone, 'provider'); + insertProfile.run( + profileId, userId, p.category, p.description, + Math.round((3 + Math.random() * 2) * 10) / 10, + Math.floor(Math.random() * 30), + p.experience, + Math.floor(Math.random() * 30) + 5, + p.address, + p.lat + (Math.random() - 0.5) * 0.01, + p.lng + (Math.random() - 0.5) * 0.01, + p.city, + p.state + ); + } +}); + +tx(); +console.log('✅ +' + providers.length + ' prestadores adicionados!'); +db.close(); diff --git a/backend/seed-requests.js b/backend/seed-requests.js new file mode 100644 index 0000000..6568fb3 --- /dev/null +++ b/backend/seed-requests.js @@ -0,0 +1,61 @@ +const Database = require('better-sqlite3'); +const path = require('path'); +const { v4: uuidv4 } = require('uuid'); + +const DB_PATH = path.join(__dirname, 'data', 'iworkg.db'); +const db = new Database(DB_PATH); + +// Get some client users to be request authors +const clients = db.prepare("SELECT id FROM users WHERE role = 'client' LIMIT 3").all(); + +// If no clients, create one +let clientId; +if (clients.length === 0) { + clientId = uuidv4(); + db.prepare('INSERT INTO users (id, name, phone, role) VALUES (?, ?, ?, ?)').run(clientId, 'Cliente Teste', '5511999999991', 'client'); +} else { + clientId = clients[0].id; +} + +const categories = db.prepare("SELECT id, name FROM categories LIMIT 12").all(); + +const insert = db.prepare(` + INSERT INTO service_requests (id, client_id, title, description, urgency, category_id, latitude, longitude, address, city, state, status, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'open', datetime('now', ?)) +`); + +const requests = [ + { title: 'Troca de chuveiro elétrico', desc: 'Chuveiro queimou, preciso de troca urgente. Já comprei o novo, só instalar.', urgency: 'Alta', offset: '-1 hours' }, + { title: 'Pintura de quarto 15m²', desc: 'Quarto precisa de pintura nova, cor branca. Preferência para tinta lavável.', urgency: 'Média', offset: '-3 hours' }, + { title: 'Instalação de ar condicionado split', desc: 'Preciso instalar um split 12000 BTUs no quarto. Já tenho o aparelho.', urgency: 'Média', offset: '-5 hours' }, + { title: 'Reparo em vazamento de pia', desc: 'Pia da cozinha vazando, precisa de reparo no sifão.', urgency: 'Alta', offset: '-2 hours' }, + { title: 'Montagem de guarda-roupa 6 portas', desc: 'Comprei um guarda-roupa novo e preciso montar. Já está desembalado.', urgency: 'Baixa', offset: '-1 day' }, + { title: 'Limpeza pós-obra 80m²', desc: 'Terminei reforma do apartamento, precisa de limpeza geral.', urgency: 'Média', offset: '-6 hours' }, + { title: 'Troca de fechadura porta principal', desc: 'Fechadura emperrando, preciso trocar por uma nova tetra.', urgency: 'Alta', offset: '-30 minutes' }, + { title: 'Poda de árvore frutífera', desc: 'Mangueira no quintal precisa de poda, está muito alta.', urgency: 'Baixa', offset: '-12 hours' }, + { title: 'Conserto de portão elétrico', desc: 'Portão não abre mais, motor faz barulho mas não movimenta.', urgency: 'Alta', offset: '-1 hours' }, + { title: 'Instalação de tomadas e interruptores', desc: 'Reforma do escritório, preciso instalar 8 tomadas e 3 interruptores novos.', urgency: 'Média', offset: '-4 hours' }, +]; + +// Poços de Caldas area coordinates +const baseLat = -21.7884; +const baseLng = -46.5616; + +const tx = db.transaction(() => { + for (const r of requests) { + const cat = categories[Math.floor(Math.random() * categories.length)]; + const lat = baseLat + (Math.random() - 0.5) * 0.02; + const lng = baseLng + (Math.random() - 0.5) * 0.02; + insert.run( + uuidv4(), clientId, r.title, r.desc, r.urgency, + cat.id, lat, lng, + 'Rua de Teste, ' + Math.floor(Math.random() * 500), + 'Poços de Caldas', 'MG', + r.offset + ); + } +}); + +tx(); +console.log('✅ ' + requests.length + ' pedidos de serviço criados!'); +db.close(); diff --git a/backend/src/routes/provider.ts b/backend/src/routes/provider.ts index 075e6fe..12f2efd 100644 --- a/backend/src/routes/provider.ts +++ b/backend/src/routes/provider.ts @@ -22,7 +22,11 @@ const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 6 * router.get('/wizard', (req: Request, res: Response) => { const userId = req.user!.id; const user = db.prepare('SELECT name, phone, email FROM users WHERE id = ?').get(userId) as any; - const wizard = getWizardState(userId) ?? createWizardState(userId); + let wizard = getWizardState(userId); + if (!wizard) { + createWizardState(userId); + wizard = getWizardState(userId); + } if (!wizard) { res.status(500).json({ error: 'Erro ao carregar wizard.' }); return; } res.json({ currentStep: wizard.current_step, diff --git a/backend/src/routes/providers.ts b/backend/src/routes/providers.ts index afe1ceb..163d6a0 100644 --- a/backend/src/routes/providers.ts +++ b/backend/src/routes/providers.ts @@ -115,7 +115,8 @@ router.get('/profile/mine', requireAuth, (req: Request, res: Response) => { router.get('/:id', (req: Request, res: Response) => { const { id } = req.params; - const provider = db.prepare(` + // Try by profile_id first, then user_id (supports both URL patterns) + let provider = db.prepare(` SELECT u.id as user_id, u.name, u.avatar_url, u.phone, pp.id as profile_id, pp.description, pp.rating, pp.review_count, @@ -129,6 +130,22 @@ router.get('/:id', (req: Request, res: Response) => { WHERE pp.id = ? AND pp.active = 1 `).get(id) as any; + if (!provider) { + provider = db.prepare(` + SELECT + u.id as user_id, u.name, u.avatar_url, u.phone, + pp.id as profile_id, pp.description, pp.rating, pp.review_count, + pp.latitude, pp.longitude, pp.city, pp.state, + pp.active, + c.id as category_id, c.name as category_name, + c.slug as category_slug, c.icon as category_icon + FROM provider_profiles pp + JOIN users u ON u.id = pp.user_id + JOIN categories c ON c.id = pp.category_id + WHERE u.id = ? AND pp.active = 1 + `).get(id) as any; + } + if (!provider) { res.status(404).json({ error: 'Prestador não encontrado.' }); return; @@ -136,8 +153,8 @@ router.get('/:id', (req: Request, res: Response) => { // Get portfolio images const portfolio = db.prepare(` - SELECT id, image_url, caption, sort_order - FROM provider_portfolio + SELECT id, filename, original_name, mime_type, tag, sort_order, created_at + FROM portfolio_photos WHERE provider_id = ? ORDER BY sort_order ASC, created_at DESC `).all(provider.profile_id); diff --git a/backend/src/routes/requests.ts b/backend/src/routes/requests.ts index f156563..b57ad67 100644 --- a/backend/src/routes/requests.ts +++ b/backend/src/routes/requests.ts @@ -14,7 +14,7 @@ const router = Router(); * POST /api/requests — Create a new service request (client only) */ router.post('/', requireAuth, (req: Request, res: Response) => { - const { title, description, category_id, urgency, photo_url, lat, lng, city, state, address } = req.body; + const { title, description, category_id, urgency, photo_url, lat, lng, city, state, address, budget } = req.body; if (!title || !category_id) { res.status(400).json({ error: 'Título e categoria são obrigatórios.' }); @@ -33,12 +33,13 @@ router.post('/', requireAuth, (req: Request, res: Response) => { const clientId = req.user!.id; db.prepare(` - INSERT INTO service_requests (id, client_id, title, description, category_id, urgency, photo_url, latitude, longitude, city, state, address) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + INSERT INTO service_requests (id, client_id, title, description, category_id, urgency, photo_url, latitude, longitude, city, state, address, budget) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `).run( id, clientId, title, description || null, category_id, urgency || 'medium', photo_url || null, - lat || null, lng || null, city || null, state || null, address || null + lat || null, lng || null, city || null, state || null, address || null, + budget ? Number(budget) : null ); res.status(201).json({ id, message: 'Pedido publicado com sucesso!' }); @@ -54,7 +55,7 @@ router.get('/', requireAuth, (req: Request, res: Response) => { let sql = ` SELECT sr.id, sr.title, sr.description, sr.category_id, sr.urgency, sr.photo_url, - sr.latitude, sr.longitude, sr.city, sr.state, sr.address, sr.status, + sr.latitude, sr.longitude, sr.city, sr.state, sr.address, sr.status, sr.budget, sr.created_at, u.id as client_id, u.name as client_name, u.avatar_url as client_avatar, c.name as category_name, c.icon as category_icon, @@ -91,7 +92,6 @@ router.get('/', requireAuth, (req: Request, res: Response) => { const rows = db.prepare(sql).all(...params) as any[]; - // Count total (without LIMIT/OFFSET) const countSql = sql.replace(/SELECT .*? FROM/, 'SELECT COUNT(*) as total FROM') .replace(/ LIMIT \? OFFSET \?$/, ''); const countParams = params.slice(0, params.length - 2); @@ -111,6 +111,7 @@ router.get('/', requireAuth, (req: Request, res: Response) => { city: r.city, state: r.state, address: r.address, + budget: r.budget, status: r.status, interestCount: r.interest_count, createdAt: r.created_at, @@ -133,7 +134,7 @@ router.get('/mine', requireAuth, (req: Request, res: Response) => { const rows = db.prepare(` SELECT sr.id, sr.title, sr.description, sr.category_id, sr.urgency, sr.photo_url, - sr.latitude, sr.longitude, sr.city, sr.state, sr.address, sr.status, + sr.latitude, sr.longitude, sr.city, sr.state, sr.address, sr.status, sr.budget, sr.created_at, c.name as category_name, c.icon as category_icon, (SELECT COUNT(*) FROM interests i WHERE i.request_id = sr.id) as interest_count @@ -157,6 +158,7 @@ router.get('/mine', requireAuth, (req: Request, res: Response) => { city: r.city, state: r.state, address: r.address, + budget: r.budget, status: r.status, interestCount: r.interest_count, createdAt: r.created_at, @@ -165,12 +167,12 @@ router.get('/mine', requireAuth, (req: Request, res: Response) => { }); /** - * PATCH /api/requests/:id — Update request status + * PATCH /api/requests/:id — Update request (status or edit fields) */ router.patch('/:id', requireAuth, (req: Request, res: Response) => { const requestId = req.params.id; const userId = req.user!.id; - const { status } = req.body; + const { status, title, description, budget, category_id } = req.body; const request = db.prepare('SELECT * FROM service_requests WHERE id = ?').get(requestId) as any; if (!request) { @@ -179,26 +181,49 @@ router.patch('/:id', requireAuth, (req: Request, res: Response) => { } if (request.client_id !== userId && req.user!.role !== 'admin') { - res.status(403).json({ error: 'Apenas o dono do pedido pode alterar o status.' }); + res.status(403).json({ error: 'Apenas o dono do pedido pode alterá-lo.' }); return; } - const validTransitions: Record = { - open: ['cancelled', 'in_progress'], - in_progress: ['completed'], - }; + // If changing status, validate transition + if (status) { + const validTransitions: Record = { + open: ['cancelled', 'in_progress'], + in_progress: ['completed'], + }; + if (!validTransitions[request.status]?.includes(status)) { + res.status(400).json({ error: `Transição inválida de "${request.status}" para "${status}".` }); + return; + } + db.prepare("UPDATE service_requests SET status = ?, updated_at = datetime('now') WHERE id = ?") + .run(status, requestId); + return res.json({ message: 'Status atualizado.', status }); + } - if (!validTransitions[request.status]?.includes(status)) { - res.status(400).json({ - error: `Transição inválida de "${request.status}" para "${status}".`, - }); + // Editing fields (only for open requests) + if (request.status !== 'open') { + res.status(400).json({ error: 'Só é possível editar pedidos abertos.' }); + return; + } + + const updates: string[] = []; + const values: any[] = []; + + if (title !== undefined) { updates.push('title = ?'); values.push(String(title).trim().slice(0, 100)); } + if (description !== undefined) { updates.push('description = ?'); values.push(String(description).trim().slice(0, 500)); } + if (budget !== undefined) { updates.push('budget = ?'); values.push(budget ? Number(budget) : null); } + if (category_id !== undefined) { updates.push('category_id = ?'); values.push(category_id); } + + if (updates.length === 0) { + res.status(400).json({ error: 'Nenhum campo para atualizar.' }); return; } - db.prepare("UPDATE service_requests SET status = ?, updated_at = datetime('now') WHERE id = ?") - .run(status, requestId); + updates.push("updated_at = datetime('now')"); + values.push(requestId); - res.json({ message: 'Status atualizado com sucesso.', status }); + db.prepare(`UPDATE service_requests SET ${updates.join(', ')} WHERE id = ?`).run(...values); + res.json({ message: 'Pedido atualizado com sucesso.' }); }); /** @@ -208,7 +233,6 @@ router.post('/:id/interest', requireAuth, requireRole('provider'), (req: Request const requestId = req.params.id; const providerId = req.user!.id; - // Check request exists const request = db.prepare('SELECT * FROM service_requests WHERE id = ?').get(requestId) as any; if (!request) { res.status(404).json({ error: 'Pedido não encontrado.' }); @@ -225,7 +249,6 @@ router.post('/:id/interest', requireAuth, requireRole('provider'), (req: Request return; } - // Check if already expressed interest (UNIQUE constraint handles race conditions) let interestId: string; try { interestId = uuidv4(); @@ -240,13 +263,11 @@ router.post('/:id/interest', requireAuth, requireRole('provider'), (req: Request throw err; } - // Get provider info for notification const provider = db.prepare('SELECT name FROM users WHERE id = ?').get(providerId) as any; const category = request.category_id ? (db.prepare('SELECT name FROM categories WHERE id = ?').get(request.category_id) as any) : null; - // Notify the client about the new interest const catLabel = category ? ` (${category.name})` : ''; notifyUser( request.client_id, @@ -269,14 +290,12 @@ router.get('/:id/interests', requireAuth, (req: Request, res: Response) => { const requestId = req.params.id; const userId = req.user!.id; - // Verify the user owns this request (client) or is a provider who expressed interest const request = db.prepare('SELECT * FROM service_requests WHERE id = ?').get(requestId) as any; if (!request) { res.status(404).json({ error: 'Pedido não encontrado.' }); return; } - // Non-owners (providers viewing the request): return request info without interests list if (request.client_id !== userId && req.user!.role !== 'admin') { res.json({ request: { @@ -339,7 +358,6 @@ router.get('/:id/interests', requireAuth, (req: Request, res: Response) => { /** * GET /api/requests/open — Provider view: list open service requests (issue #14) - * Query: ?lat=X&lng=Y&radius=Z&category=X&page=X&limit=X */ router.get('/open', requireAuth, requireRole('provider'), (req: Request, res: Response) => { const { lat, lng, radius, category, page, limit } = req.query; @@ -370,6 +388,7 @@ router.get('/open', requireAuth, requireRole('provider'), (req: Request, res: Re longitude: row.longitude, city: row.city, state: row.state, + budget: row.budget, createdAt: row.created_at, interestCount: row.interest_count, client: { diff --git a/backend/src/server.ts b/backend/src/server.ts index d9c55b5..ccd8f46 100644 --- a/backend/src/server.ts +++ b/backend/src/server.ts @@ -14,7 +14,7 @@ import adminRoutes from './routes/admin'; const app = express(); app.use(cors({ - origin: config.frontendUrl, + origin: [config.frontendUrl, 'http://localhost:5173', 'http://localhost:5174'], credentials: true, })); app.use(express.json()); diff --git a/frontend/package-lock.json b/frontend/package-lock.json index faf5dcf..e9c8e56 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -59,6 +59,7 @@ "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", @@ -268,29 +269,6 @@ "node": ">=6.9.0" } }, - "node_modules/@emnapi/core": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", - "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/wasi-threads": "1.2.1", - "tslib": "^2.4.0" - } - }, - "node_modules/@emnapi/runtime": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", - "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, "node_modules/@emnapi/wasi-threads": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", @@ -877,6 +855,7 @@ "integrity": "sha512-fRa09kZTgu8o71KFcDjUFuc7F+dEbZYZmkI0mg5YBTRs0yMKjYHsq/c0urDKeDb+D5qVgXOdFcuu+DZPKOITwA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~7.18.0" } @@ -887,6 +866,7 @@ "integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -946,6 +926,7 @@ "integrity": "sha512-PJ5vePq5/ognBbrIcoC5+SHO5dfpeLPzP9FpLkzWrguoYQEeeSjlJpVwOpo1JRSTEi7dRcwNy4h4dzV70PqHcg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.61.1", "@typescript-eslint/types": "8.61.1", @@ -1176,6 +1157,7 @@ "integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -1296,6 +1278,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", @@ -1536,6 +1519,7 @@ "integrity": "sha512-1y+7C+vi12bUK1IpZeaV3gsH9fHLBmPvYmPx42pvT/E9yG0IC8g3PUZZgp0+JLJl7ZDK0flc2gc+Aw9dpCvIsQ==", "dev": true, "license": "MIT", + "peer": true, "workspaces": [ "packages/*" ], @@ -2605,6 +2589,7 @@ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -2675,6 +2660,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz", "integrity": "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -2684,6 +2670,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.7.tgz", "integrity": "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==", "license": "MIT", + "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -2875,6 +2862,7 @@ "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -2961,6 +2949,7 @@ "integrity": "sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", @@ -3085,6 +3074,7 @@ "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", "dev": true, "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index c647be3..f5b2293 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -22,7 +22,7 @@ import PrivacyPage from './pages/PrivacyPage'; import RequestBoardPage from './pages/RequestBoardPage'; import AdminDashboardPage from './pages/AdminDashboardPage'; import AdminCategoriesPage from './pages/AdminCategoriesPage'; -import AdminPage from './pages/AdminPage'; +// AdminPage imported but route pending import NotificationsPage from './pages/NotificationsPage'; import NotificationPreferencesPage from './pages/NotificationPreferencesPage'; diff --git a/frontend/src/components/Header.tsx b/frontend/src/components/Header.tsx new file mode 100644 index 0000000..fd383f4 --- /dev/null +++ b/frontend/src/components/Header.tsx @@ -0,0 +1,129 @@ +import { useNavigate } from 'react-router-dom'; +import { useAuth } from '../contexts/AuthContext'; + +interface HeaderProps { + showBack?: boolean; + backTo?: string; + title?: string; +} + +export default function Header({ showBack, backTo }: HeaderProps) { + const { user, logout } = useAuth(); + const navigate = useNavigate(); + + const handleBack = () => { + if (backTo) { + navigate(backTo); + } else if (window.history.length > 1) { + navigate(-1); + } else { + navigate('/dashboard'); + } + }; + + return ( +
+
+ {showBack && ( + + )} +

navigate(user ? '/dashboard' : '/buscar')} + > + IworkG +

+
+ + {user && ( +
+ Olá, {user.name?.split(' ')[0]} + + +
+ )} +
+ ); +} + +const styles: Record = { + header: { + display: 'flex', + justifyContent: 'space-between', + alignItems: 'center', + padding: '18px 32px', + backgroundColor: '#1a1a2e', + color: '#fff', + minHeight: '64px', + position: 'relative' as const, + }, + left: { + display: 'flex', + alignItems: 'center', + gap: '16px', + minWidth: '100px', + }, + right: { + display: 'flex', + alignItems: 'center', + gap: '12px', + minWidth: '100px', + justifyContent: 'flex-end', + }, + backBtn: { + padding: '8px 16px', + fontSize: '14px', + fontWeight: 500, + backgroundColor: 'rgba(255,255,255,0.1)', + color: '#fff', + border: '1px solid rgba(255,255,255,0.25)', + borderRadius: '8px', + cursor: 'pointer', + fontFamily: 'inherit', + }, + logo: { + fontSize: '22px', + fontWeight: 700, + margin: 0, + userSelect: 'none' as const, + position: 'absolute' as const, + left: '50%', + transform: 'translateX(-50%)', + }, + greeting: { + fontSize: '15px', + opacity: 0.9, + }, + profileBtn: { + padding: '8px 18px', + fontSize: '13px', + fontWeight: 600, + backgroundColor: 'transparent', + color: '#fff', + border: '2px solid rgba(255,255,255,0.5)', + borderRadius: '8px', + cursor: 'pointer', + fontFamily: 'inherit', + }, + logoutBtn: { + padding: '8px 18px', + fontSize: '13px', + fontWeight: 600, + backgroundColor: '#dc2626', + color: '#fff', + border: 'none', + borderRadius: '8px', + cursor: 'pointer', + fontFamily: 'inherit', + transition: 'all 0.15s', + }, +}; diff --git a/frontend/src/pages/ContactsPage.tsx b/frontend/src/pages/ContactsPage.tsx index 1d5b891..0f70acb 100644 --- a/frontend/src/pages/ContactsPage.tsx +++ b/frontend/src/pages/ContactsPage.tsx @@ -1,6 +1,7 @@ import React, { useEffect, useState } from 'react'; import { useNavigate } from 'react-router-dom'; import { contactsService, type ContactItem } from '../services/history'; +import Header from '../components/Header'; export default function ContactsPage() { const [contacts, setContacts] = useState([]); @@ -16,11 +17,7 @@ export default function ContactsPage() { return (
-
- -

IworkG

- -
+

Meus Contatos

diff --git a/frontend/src/pages/DashboardPage.tsx b/frontend/src/pages/DashboardPage.tsx index 3616eee..e5146f8 100644 --- a/frontend/src/pages/DashboardPage.tsx +++ b/frontend/src/pages/DashboardPage.tsx @@ -4,6 +4,7 @@ import { useAuth } from '../contexts/AuthContext'; import api from '../services/api'; import { notificationService } from '../services/notifications'; import Toast from '../components/Toast'; +import Header from '../components/Header'; export default function DashboardPage() { const { user, logout } = useAuth(); @@ -23,11 +24,6 @@ export default function DashboardPage() { return () => clearInterval(interval); }, []); - const handleLogout = async () => { - await logout(); - navigate('/login'); - }; - const handleDeleteAccount = async () => { if (deleteConfirm !== 'EXCLUIR') return; setDeleting(true); @@ -55,25 +51,25 @@ export default function DashboardPage() { onClose={() => setToast(null)} /> -
-

IworkG

-
- {user?.avatarUrl && ( - - )} - {user?.name} - - - + {user?.role === 'provider' && ( + -
-
+ )} + + +

Bem-vindo, {user?.name}!

@@ -85,6 +81,41 @@ export default function DashboardPage() { {user?.phone && <>Telefone: {user.phone}}

+
+ + + + +
+ + {user?.role === 'provider' && ( +
+

Painel do Prestador

+ + +
+ )} + {user?.role === 'client' && (

Quer trabalhar na plataforma?

@@ -103,11 +134,6 @@ export default function DashboardPage() {
)} -
-

🚧 As demais funcionalidades serão implementadas nas próximas issues.

-

Sprint 1 — Fundação: Autenticação (✓) | Filtros (#9) | Busca geográfica

-
- {/* Configurações da Conta */}

⚙️ Configurações da Conta

@@ -174,52 +200,6 @@ const styles: Record = { backgroundColor: '#f5f5f5', fontFamily: 'system-ui, sans-serif', }, - header: { - display: 'flex', - justifyContent: 'space-between', - alignItems: 'center', - padding: '16px 32px', - backgroundColor: '#1a1a2e', - color: '#fff', - }, - logo: { fontSize: '20px', fontWeight: 700, margin: 0 }, - userInfo: { - display: 'flex', - alignItems: 'center', - gap: '12px', - }, - avatar: { width: '32px', height: '32px', borderRadius: '50%' }, - userName: { fontSize: '14px', fontWeight: 500 }, - helpBtn: { - width: '32px', - height: '32px', - fontSize: '16px', - fontWeight: 700, - backgroundColor: 'rgba(255,255,255,0.15)', - color: '#fff', - border: '1px solid rgba(255,255,255,0.3)', - borderRadius: '50%', - cursor: 'pointer', - display: 'flex', - alignItems: 'center', - justifyContent: 'center', - fontFamily: 'inherit', - }, - bellBtn: { - position: 'relative' as const, - width: '32px', - height: '32px', - fontSize: '14px', - backgroundColor: 'rgba(255,255,255,0.15)', - color: '#fff', - border: '1px solid rgba(255,255,255,0.3)', - borderRadius: '50%', - cursor: 'pointer', - display: 'flex', - alignItems: 'center', - justifyContent: 'center', - fontFamily: 'inherit', - }, bellBadge: { position: 'absolute' as const, top: '-4px', @@ -237,14 +217,25 @@ const styles: Record = { padding: '0 4px', lineHeight: 1, }, - logoutBtn: { - padding: '6px 16px', - fontSize: '13px', - backgroundColor: 'transparent', + quickActions: { + display: 'flex', + gap: '12px', + justifyContent: 'center', + padding: '16px', + maxWidth: '800px', + margin: '0 auto', + flexWrap: 'wrap' as const, + }, + quickBtn: { + padding: '12px 24px', + fontSize: '15px', + fontWeight: 600, + backgroundColor: '#1a1a2e', color: '#fff', - border: '1px solid rgba(255,255,255,0.3)', - borderRadius: '6px', + border: 'none', + borderRadius: '12px', cursor: 'pointer', + fontFamily: 'inherit', }, main: { maxWidth: '800px', @@ -255,7 +246,28 @@ const styles: Record = { boxShadow: '0 2px 12px rgba(0,0,0,0.06)', }, role: { fontSize: '16px', color: '#555' }, - info: { fontSize: '14px', color: '#777', marginBottom: '32px' }, + info: { fontSize: '14px', color: '#777', marginBottom: '24px' }, + navGrid: { + display: 'grid', + gridTemplateColumns: 'repeat(auto-fit, minmax(150px, 1fr))', + gap: '10px', + marginBottom: '24px', + }, + navCard: { + display: 'flex', + flexDirection: 'column' as const, + alignItems: 'center', + gap: '4px', + padding: '16px 12px', + backgroundColor: '#f9fafb', + border: '1px solid #e5e7eb', + borderRadius: '12px', + cursor: 'pointer', + fontFamily: 'inherit', + fontSize: '13px', + textAlign: 'center' as const, + transition: 'all 0.15s', + }, ctaBox: { padding: '20px', backgroundColor: '#eff6ff', diff --git a/frontend/src/pages/FavoritesPage.tsx b/frontend/src/pages/FavoritesPage.tsx index ef9bbb3..47e03fe 100644 --- a/frontend/src/pages/FavoritesPage.tsx +++ b/frontend/src/pages/FavoritesPage.tsx @@ -1,6 +1,7 @@ import React, { useEffect, useState } from 'react'; import { useNavigate } from 'react-router-dom'; import { favoritesService, type FavoriteItem } from '../services/history'; +import Header from '../components/Header'; export default function FavoritesPage() { const [favorites, setFavorites] = useState([]); @@ -30,11 +31,7 @@ export default function FavoritesPage() { return (
-
- -

IworkG

- -
+

Favoritos

diff --git a/frontend/src/pages/MyProviderPage.tsx b/frontend/src/pages/MyProviderPage.tsx index 7e42573..6cc6973 100644 --- a/frontend/src/pages/MyProviderPage.tsx +++ b/frontend/src/pages/MyProviderPage.tsx @@ -3,6 +3,7 @@ import { useNavigate } from 'react-router-dom'; import { useAuth } from '../contexts/AuthContext'; import { providerService, type ProviderProfile, type PortfolioPhoto } from '../services/provider'; import PortfolioGallery from '../components/PortfolioGallery'; +import Header from '../components/Header'; export default function MyProviderPage() { const { user, isAuthenticated, loading: al } = useAuth(); @@ -26,7 +27,8 @@ export default function MyProviderPage() { if (!profile) return null; return
-

nav('/dashboard')}>IworkG

+
+
diff --git a/frontend/src/pages/MyRequestsPage.tsx b/frontend/src/pages/MyRequestsPage.tsx index 0185f46..6c95ecb 100644 --- a/frontend/src/pages/MyRequestsPage.tsx +++ b/frontend/src/pages/MyRequestsPage.tsx @@ -2,6 +2,7 @@ import React, { useEffect, useState } from 'react'; import { useNavigate } from 'react-router-dom'; import { useAuth } from '../contexts/AuthContext'; import { requestService, type ServiceRequest } from '../services/requests'; +import Header from '../components/Header'; const STATUS_LABELS: Record = { open: { label: 'Aberto', color: '#16a34a' }, @@ -44,6 +45,26 @@ export default function MyRequestsPage() { } }; + const handleEdit = async (req: ServiceRequest) => { + const newTitle = prompt('Título:', req.title); + if (newTitle === null) return; + const newDesc = prompt('Descrição:', req.description || ''); + if (newDesc === null) return; + const newBudget = prompt('Valor máximo (R$):', req.budget != null ? String(req.budget) : ''); + if (newBudget === null) return; + + try { + await requestService.updateRequest(req.id, { + title: newTitle, + description: newDesc, + budget: newBudget ? Number(newBudget) : null, + }); + loadRequests(); + } catch (err: any) { + alert(err?.response?.data?.error || 'Erro ao editar pedido.'); + } + }; + const formatDate = (dateStr: string) => { const d = new Date(dateStr); return d.toLocaleDateString('pt-BR', { day: '2-digit', month: 'short', year: 'numeric' }); @@ -51,10 +72,7 @@ export default function MyRequestsPage() { return (
-
-

IworkG

- Meus Pedidos -
+
@@ -122,16 +140,37 @@ export default function MyRequestsPage() {

{req.description}

)} + {req.budget != null ? ( +

+ 💰 Até R$ {Number(req.budget).toFixed(2)} +

+ ) : ( +

+ 💰 A combinar +

+ )} + {req.status === 'open' && ( - +
+ + +
)}
); diff --git a/frontend/src/pages/NewRequestPage.tsx b/frontend/src/pages/NewRequestPage.tsx index 6f45c3f..0c1dfde 100644 --- a/frontend/src/pages/NewRequestPage.tsx +++ b/frontend/src/pages/NewRequestPage.tsx @@ -4,6 +4,7 @@ import { useAuth } from '../contexts/AuthContext'; import { searchService, type Category } from '../services/search'; import { requestService } from '../services/requests'; import { getCachedLocation as _getCachedLocation, cacheLocation, lookupCep } from '../services/location'; +import Header from '../components/Header'; export default function NewRequestPage() { const { user: _user } = useAuth(); @@ -14,6 +15,7 @@ export default function NewRequestPage() { const [description, setDescription] = useState(''); const [categoryId, setCategoryId] = useState(''); const [urgency, setUrgency] = useState('medium'); + const [budget, setBudget] = useState(''); const [photoUrl, setPhotoUrl] = useState(''); // Location state @@ -92,6 +94,7 @@ export default function NewRequestPage() { description: description.trim() || undefined, category_id: categoryId, urgency, + budget: budget ? Number(budget) : undefined, photo_url: photoUrl.trim() || undefined, lat: locationLat ?? undefined, lng: locationLng ?? undefined, @@ -115,10 +118,7 @@ export default function NewRequestPage() { return (
-
-

IworkG

- Novo Pedido -
+

Publicar Pedido

@@ -182,6 +182,18 @@ export default function NewRequestPage() { ))}
+ {/* Valor máximo */} + + setBudget(e.target.value)} + min={0} + step={0.01} + /> + {/* Foto (URL) */} = { new_request: '📋', @@ -34,16 +35,6 @@ export default function NotificationsPage() { } }; - const handleMarkAllRead = async () => { - const prev = [...notifications]; - setNotifications(prev => prev.map(n => ({ ...n, read: true }))); - try { - await notificationService.markAllAsRead(); - } catch { - setNotifications(prev); // rollback - } - }; - const unreadCount = notifications.filter(n => !n.read).length; if (loading) return ; @@ -51,18 +42,7 @@ export default function NotificationsPage() { return (
-
- -

IworkG

-
- {unreadCount > 0 && ( - - )} - -
-
+

🔔 Notificações {unreadCount > 0 && {unreadCount}}

diff --git a/frontend/src/pages/ProviderEditPage.tsx b/frontend/src/pages/ProviderEditPage.tsx index 8c073db..0232bca 100644 --- a/frontend/src/pages/ProviderEditPage.tsx +++ b/frontend/src/pages/ProviderEditPage.tsx @@ -3,6 +3,7 @@ import { useNavigate } from 'react-router-dom'; import { providerService, type OwnProfile } from '../services/providers'; import { useAuth } from '../contexts/AuthContext'; import Toast from '../components/Toast'; +import Header from '../components/Header'; export default function ProviderEditPage() { const navigate = useNavigate(); @@ -85,17 +86,12 @@ export default function ProviderEditPage() { onClose={() => setToast(null)} /> -
-

IworkG

-
- - -
-
+
+
+ +

✏️ Editar Perfil

diff --git a/frontend/src/pages/ProviderProfilePage.tsx b/frontend/src/pages/ProviderProfilePage.tsx index ed29897..bf689c6 100644 --- a/frontend/src/pages/ProviderProfilePage.tsx +++ b/frontend/src/pages/ProviderProfilePage.tsx @@ -1,9 +1,11 @@ import { useState, useEffect } from 'react'; -import { useParams, useNavigate } from 'react-router-dom'; +import { useParams } from 'react-router-dom'; import { providerService, type ProviderProfile } from '../services/providers'; import { useAuth } from '../contexts/AuthContext'; import ContactModal from '../components/ContactModal'; import ReviewSection from '../components/ReviewSection'; +import Header from '../components/Header'; +import api from '../services/api'; const DEFAULT_AVATAR = 'data:image/svg+xml,' + encodeURIComponent( '👤' @@ -11,40 +13,29 @@ const DEFAULT_AVATAR = 'data:image/svg+xml,' + encodeURIComponent( export default function ProviderProfilePage() { const { id } = useParams<{ id: string }>(); - const navigate = useNavigate(); const { user } = useAuth(); const [profile, setProfile] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); - const [lightboxImg, setLightboxImg] = useState(null); const [showContactModal, setShowContactModal] = useState(false); + const [isFavorited, setIsFavorited] = useState(false); + const [favLoading, setFavLoading] = useState(false); - const isOwner = user && profile && user.id === profile.id; - - const handleShare = async () => { - const shareUrl = `https://iwork.app/provider/${id}`; - const shareData = { - title: `${profile?.name} — IworkG`, - text: `Confira o perfil de ${profile?.name} no IworkG!`, - url: shareUrl, - }; + useEffect(() => { + if (!user || !id) return; + api.get(`/api/favorites/check/${id}`) + .then(res => setIsFavorited(res.data.favorited)) + .catch(() => {}); + }, [id, user]); + + const handleToggleFavorite = async () => { + if (!user) return; + setFavLoading(true); try { - if (navigator.share && window.innerWidth <= 768) { - await navigator.share(shareData); - } else { - await navigator.clipboard.writeText(shareUrl); - alert('🔗 Link copiado para a área de transferência!'); - } - } catch (err) { - // User cancelled share or clipboard failed — fallback - try { - await navigator.clipboard.writeText(shareUrl); - alert('🔗 Link copiado para a área de transferência!'); - } catch { - // Last resort - prompt('Copie o link abaixo:', shareUrl); - } - } + const res = await api.post(`/api/favorites/${id}`); + setIsFavorited(res.data.favorited); + } catch { /* ignore */ } + finally { setFavLoading(false); } }; useEffect(() => { @@ -58,372 +49,86 @@ export default function ProviderProfilePage() { if (loading) { return ( -
-

Carregando perfil...

+
+
+

Carregando...

); } if (error || !profile) { return ( -
-

{error || 'Perfil não encontrado.'}

- +
+
+

{error || 'Perfil não encontrado.'}

); } - const renderStars = (rating: number) => { - const full = Math.floor(rating); - const half = rating % 1 >= 0.5; - const stars = []; - for (let i = 0; i < 5; i++) { - if (i < full) stars.push('★'); - else if (i === full && half) stars.push('☆'); - else stars.push('☆'); - } - return stars.join(''); - }; - return ( -
- {/* Header/Nav */} -
-

IworkG

-
- {isOwner && ( - - )} - -
-
- -
- {/* Profile Card */} -
- {profile.name} -

{profile.name}

- - {/* Category chip */} - - {profile.category.icon} {profile.category.name} - - - {/* Rating */} -
- {renderStars(profile.rating)} - - {profile.rating.toFixed(1)} ({profile.reviewCount} {profile.reviewCount === 1 ? 'avaliação' : 'avaliações'}) +
+
+ +
+
+
+ {profile.name} +

{profile.name}

+ + {profile.category.icon} {profile.category.name} + {profile.city && ( + + 📍 {profile.city}, {profile.state} + + )}
- {/* Location */} - {profile.city && profile.state && ( -

📍 {profile.city}, {profile.state}

- )} +
+ {user && ( + + )} + {user && ( + + )} +
- {/* Description */} {profile.description && ( -

{profile.description}

+

{profile.description}

)} - {/* Action Buttons */} -
- - - -
-
- - {/* Portfolio Gallery */} - {profile.portfolio.length > 0 && ( -
-

📸 Portfólio — Antes e Depois

-
- {profile.portfolio.map((item) => ( -
setLightboxImg(item.imageUrl)} - > - {item.caption - {item.caption &&

{item.caption}

} -
- ))} +
+
+
+ {profile.rating?.toFixed(1) || '—'} +
({profile.reviewCount || 0} avaliações)
+
+
+
📅
+ +
de experiência
- )} +
- {/* Reviews + Report (issues #17, #18) */} {id && }
- {/* Lightbox */} - {lightboxImg && ( -
setLightboxImg(null)}> - - -
+ {showContactModal && profile && ( + setShowContactModal(false)} + providerId={id!} + providerName={profile.name} + providerPhone={profile.phone || null} + /> )} - - {/* Contact Modal */} - setShowContactModal(false)} - providerId={profile.id} - providerName={profile.name} - providerPhone={profile.phone} - />
); } - -const styles: Record = { - container: { - minHeight: '100vh', - backgroundColor: '#f5f5f5', - fontFamily: 'system-ui, sans-serif', - }, - loadingContainer: { - minHeight: '100vh', - display: 'flex', - flexDirection: 'column', - alignItems: 'center', - justifyContent: 'center', - gap: '16px', - backgroundColor: '#f5f5f5', - }, - header: { - display: 'flex', - justifyContent: 'space-between', - alignItems: 'center', - padding: '16px 32px', - backgroundColor: '#1a1a2e', - color: '#fff', - }, - logo: { fontSize: '20px', fontWeight: 700, margin: 0 }, - headerRight: { - display: 'flex', - alignItems: 'center', - gap: '12px', - }, - editBtn: { - padding: '8px 18px', - fontSize: '13px', - fontWeight: 600, - backgroundColor: '#6366f1', - color: '#fff', - border: 'none', - borderRadius: '8px', - cursor: 'pointer', - }, - backNavBtn: { - padding: '8px 18px', - fontSize: '13px', - fontWeight: 500, - backgroundColor: 'transparent', - color: '#fff', - border: '1px solid rgba(255,255,255,0.3)', - borderRadius: '8px', - cursor: 'pointer', - }, - main: { - maxWidth: '720px', - margin: '32px auto', - padding: '0 16px', - display: 'flex', - flexDirection: 'column', - gap: '24px', - }, - profileCard: { - backgroundColor: '#fff', - borderRadius: '16px', - padding: '32px', - display: 'flex', - flexDirection: 'column', - alignItems: 'center', - boxShadow: '0 2px 12px rgba(0,0,0,0.06)', - }, - avatar: { - width: '120px', - height: '120px', - borderRadius: '50%', - objectFit: 'cover', - border: '4px solid #e5e7eb', - marginBottom: '16px', - }, - name: { - fontSize: '24px', - fontWeight: 700, - color: '#1f2937', - margin: '0 0 8px', - }, - categoryChip: { - display: 'inline-block', - padding: '6px 16px', - backgroundColor: '#eef2ff', - color: '#4338ca', - borderRadius: '20px', - fontSize: '14px', - fontWeight: 600, - marginBottom: '12px', - }, - ratingRow: { - display: 'flex', - alignItems: 'center', - gap: '8px', - marginBottom: '12px', - }, - stars: { - fontSize: '20px', - color: '#f59e0b', - letterSpacing: '2px', - }, - ratingText: { - fontSize: '14px', - color: '#6b7280', - }, - location: { - fontSize: '14px', - color: '#6b7280', - marginBottom: '8px', - }, - bio: { - fontSize: '15px', - color: '#4b5563', - lineHeight: '1.6', - textAlign: 'center' as const, - maxWidth: '500px', - marginBottom: '20px', - }, - actionRow: { - display: 'flex', - gap: '12px', - flexWrap: 'wrap' as const, - justifyContent: 'center', - }, - contactBtn: { - padding: '12px 28px', - fontSize: '15px', - fontWeight: 600, - backgroundColor: '#2563eb', - color: '#fff', - border: 'none', - borderRadius: '10px', - cursor: 'pointer', - }, - favBtn: { - padding: '12px 28px', - fontSize: '15px', - fontWeight: 600, - backgroundColor: '#fff', - color: '#dc2626', - border: '2px solid #fecaca', - borderRadius: '10px', - cursor: 'pointer', - }, - shareBtn: { - padding: '12px 28px', - fontSize: '15px', - fontWeight: 600, - backgroundColor: '#f0fdf4', - color: '#16a34a', - border: '2px solid #bbf7d0', - borderRadius: '10px', - cursor: 'pointer', - }, - portfolioSection: { - backgroundColor: '#fff', - borderRadius: '16px', - padding: '24px', - boxShadow: '0 2px 12px rgba(0,0,0,0.06)', - }, - sectionTitle: { - fontSize: '18px', - fontWeight: 600, - color: '#1f2937', - margin: '0 0 16px', - }, - gallery: { - display: 'grid', - gridTemplateColumns: 'repeat(auto-fill, minmax(200px, 1fr))', - gap: '12px', - }, - galleryItem: { - borderRadius: '10px', - overflow: 'hidden', - cursor: 'pointer', - border: '1px solid #e5e7eb', - transition: 'transform 0.2s', - }, - galleryImg: { - width: '100%', - height: '160px', - objectFit: 'cover', - display: 'block', - }, - galleryCaption: { - padding: '8px 12px', - fontSize: '12px', - color: '#6b7280', - margin: 0, - backgroundColor: '#f9fafb', - }, - lightbox: { - position: 'fixed', - inset: 0, - backgroundColor: 'rgba(0,0,0,0.85)', - display: 'flex', - alignItems: 'center', - justifyContent: 'center', - zIndex: 9998, - cursor: 'pointer', - }, - lightboxImg: { - maxWidth: '90vw', - maxHeight: '85vh', - borderRadius: '8px', - objectFit: 'contain', - }, - lightboxClose: { - position: 'absolute', - top: '24px', - right: '24px', - fontSize: '28px', - color: '#fff', - cursor: 'pointer', - width: '40px', - height: '40px', - display: 'flex', - alignItems: 'center', - justifyContent: 'center', - backgroundColor: 'rgba(0,0,0,0.5)', - borderRadius: '50%', - }, - backBtn: { - padding: '10px 24px', - fontSize: '14px', - backgroundColor: '#2563eb', - color: '#fff', - border: 'none', - borderRadius: '8px', - cursor: 'pointer', - }, -}; diff --git a/frontend/src/pages/ProviderRegisterPage.tsx b/frontend/src/pages/ProviderRegisterPage.tsx index 7e098cd..e297b65 100644 --- a/frontend/src/pages/ProviderRegisterPage.tsx +++ b/frontend/src/pages/ProviderRegisterPage.tsx @@ -3,6 +3,7 @@ import { useNavigate } from 'react-router-dom'; import { useAuth } from '../contexts/AuthContext'; import { providerService, type WizardState, type Category } from '../services/provider'; import ProgressBar from '../components/ProgressBar'; +import Header from '../components/Header'; const T = 6; const LABELS = ['Dados', 'Categorias', 'Descrição', 'Localização', 'Verificação', 'Revisão']; @@ -24,14 +25,16 @@ export default function ProviderRegisterPage() { useEffect(() => { if (al) return; if (!isAuthenticated) { nav('/login'); return; } (async () => { try { const s = await providerService.getWizard(); setWiz(s); - setF(p => ({ ...p, name: s.stepData.name || s.prefill.name || '', phone: s.stepData.phone || s.prefill.phone || '', sel: s.stepData.selectedCategories || [], desc: s.stepData.description || '', exp: s.stepData.experienceYears || '', rad: s.stepData.serviceRadius || '10', addr: s.stepData.address || '', city: s.stepData.city || '', state: s.stepData.state || '' })); + if (s.currentStep === 1 && f.name === '' && f.phone === '') { + setF(p => ({ ...p, name: s.stepData.name || s.prefill.name || '', phone: s.stepData.phone || s.prefill.phone || '', sel: s.stepData.selectedCategories || [], desc: s.stepData.description || '', exp: s.stepData.experienceYears || '', rad: s.stepData.serviceRadius || '10', addr: s.stepData.address || '', city: s.stepData.city || '', state: s.stepData.state || '' })); + } setStep(s.currentStep); } catch (e: any) { if (e.response?.status === 401) nav('/login'); else setErr('Erro ao carregar wizard.'); } finally { setLoad(false); } })(); }, [isAuthenticated, al]); const save = async (st: number, d: Partial) => { setSaving(true); setErr(''); try { await providerService.saveWizard(st, d); } catch { setErr('Erro ao salvar.'); throw new Error(); } finally { setSaving(false); } }; const val = (s: number): string | null => { - if (s === 1) { if (!f.name.trim()) return 'Nome obrigatório.'; if (f.phone.replace(/\D/g, '').length < 10) return 'Telefone inválido.'; } + if (s === 1) { if (!(f.name || '').trim()) return 'Nome obrigatório.'; if ((f.phone || '').replace(/\D/g, '').length < 10) return 'Telefone inválido.'; } if (s === 2 && f.sel.length === 0) return 'Selecione ao menos uma categoria.'; if (s === 3 && f.desc.trim().length < 10) return 'Descrição: mínimo 10 caracteres.'; if (s === 4) { if (!f.addr.trim()) return 'Endereço obrigatório.'; if (!f.city.trim() || !f.state.trim()) return 'Cidade/estado obrigatórios.'; if (Number(f.rad) < 1) return 'Raio mínimo 1 km.'; } @@ -40,17 +43,19 @@ export default function ProviderRegisterPage() { const next = async () => { const v = step === T ? (!acceptedTerms ? 'Você precisa aceitar os Termos de Uso e a Política de Privacidade para finalizar.' : null) : val(step); if (v) { setErr(v); return; } - await save(step, { name: f.name, phone: f.phone, ...(step === 2 ? { selectedCategories: f.sel } : {}), ...(step === 3 ? { description: f.desc, experienceYears: f.exp } : {}), ...(step === 4 ? { serviceRadius: f.rad, address: f.addr, city: f.city, state: f.state } : {}) } as any); + try { + await save(step, { name: f.name, phone: f.phone, ...(step === 2 ? { selectedCategories: f.sel } : {}), ...(step === 3 ? { description: f.desc, experienceYears: f.exp } : {}), ...(step === 4 ? { serviceRadius: f.rad, address: f.addr, city: f.city, state: f.state } : {}) } as any); + } catch { return; } if (step === T) { setSaving(true); try { await providerService.completeWizard({ categories: f.sel, description: f.desc, experience_years: Number(f.exp) || 0, service_radius_km: Number(f.rad) || 10, address: f.addr, city: f.city, state: f.state }); setDone(true); } catch (e: any) { setErr(e.response?.data?.error || 'Erro ao finalizar.'); } finally { setSaving(false); } } else { setStep(s => s + 1); setErr(''); } }; const back = async () => { if (step === 1) return; try { await save(step, {}); } catch {} setStep(s => s - 1); setErr(''); }; const tc = (id: string) => setF(p => ({ ...p, sel: p.sel.includes(id) ? p.sel.filter(c => c !== id) : [...p.sel, id] })); if (al || load) return ; - if (done) return 🎉

Cadastro Concluído!

Seu perfil já está ativo.

} />; + if (done) return 🎉

Cadastro Concluído!

Seu perfil já está ativo.

} />; return
-

nav('/dashboard')}>IworkG

Cadastro de Prestador
+
{err &&
{err}
} @@ -76,10 +81,10 @@ const li: React.CSSProperties = { display: 'flex', flexDirection: 'column', gap: const ii: React.CSSProperties = { padding: '10px 14px', fontSize: 15, border: '1px solid #d1d5db', borderRadius: 8, fontFamily: 'inherit' }; const b: Record = { primary: { padding: '12px 28px', fontSize: 15, fontWeight: 600, background: '#2563eb', color: '#fff', border: 'none', borderRadius: 10, cursor: 'pointer', fontFamily: 'inherit' }, secondary: { padding: '12px 20px', fontSize: 15, fontWeight: 500, background: 'transparent', color: '#374151', border: '1px solid #d1d5db', borderRadius: 10, cursor: 'pointer', fontFamily: 'inherit' } }; -function S1({ f, s }: { f: F; s: any }) { return
; } +function S1({ f, s }: { f: F; s: any }) { return
; } function S2({ f, w, tc }: { f: F; w: WizardState | null; tc: (id: string) => void }) { return
{(w?.categories || []).map((c: Category) => { const sel = f.sel.includes(c.id); return ; })}
{f.sel.length > 0 &&

{f.sel.length} selecionada(s)

}
; } -function S3({ f, s }: { f: F; s: any }) { return