Update dependencies and enhance Dashboard and Landing pages with new features and UI improvements

This commit is contained in:
2026-01-14 09:52:19 +00:00
parent 58e5889b89
commit 4339b79455
7 changed files with 2062 additions and 997 deletions

1593
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -9,17 +9,19 @@
"web": "expo start --web"
},
"dependencies": {
"expo": "~54.0.27",
"expo-status-bar": "~3.0.9",
"@react-native-async-storage/async-storage": "1.23.1",
"@react-navigation/bottom-tabs": "^6.5.11",
"@react-navigation/native": "^6.1.9",
"@react-navigation/native-stack": "^6.9.17",
"@react-navigation/bottom-tabs": "^6.5.11",
"react-native-screens": "~3.31.1",
"react-native-safe-area-context": "4.10.1",
"@react-native-async-storage/async-storage": "1.23.1",
"expo": "~54.0.27",
"expo-status-bar": "~3.0.9",
"nanoid": "^5.0.7",
"react": "19.1.0",
"react-dom": "19.1.0",
"react-native": "0.81.5",
"nanoid": "^5.0.7"
"react-native-safe-area-context": "4.10.1",
"react-native-screens": "~3.31.1",
"react-native-web": "^0.21.0"
},
"devDependencies": {
"@types/react": "~19.1.0",

303
web/GUIA_COMPLETO_APACHE.md Normal file
View File

@@ -0,0 +1,303 @@
# 🚀 Guia Completo - Passo a Passo Apache
Guia super simples com comandos prontos para copiar e colar.
---
## 📍 ONDE ESTÁ VOCÊ AGORA?
-**Passos 1-2:** No seu **COMPUTADOR MAC** (onde está o código)
-**Passos 3-10:** No **SERVIDOR** (via SSH)
---
## ✅ PASSO 1: Criar o Build (NO SEU MAC)
**1.1 - Abra o Terminal no seu Mac**
**1.2 - Copie e cole este comando:**
```bash
cd /Users/230417/SmartAgendaMobile/web
```
**1.3 - Pressione Enter**
**1.4 - Copie e cole este comando:**
```bash
npm run build
```
**1.5 - Pressione Enter e aguarde (1-2 minutos)**
**1.6 - Verifique se funcionou (deve aparecer "✓ built"):**
```bash
ls -la dist/
```
**Deve aparecer:** `index.html` e uma pasta `assets/`
---
## ✅ PASSO 2: Verificar Build (OPCIONAL - NO SEU MAC)
**2.1 - Ainda no terminal do Mac, copie e cole:**
```bash
npm run preview
```
**2.2 - Abra o navegador e acesse:** `http://localhost:4173`
**2.3 - Se estiver funcionando, volte ao terminal e pressione:** `Ctrl+C`
---
## ✅ PASSO 3: Conectar ao Servidor
**3.1 - No terminal do Mac, copie e cole (ajuste o IP/domínio):**
```bash
ssh santospap@SEU-IP-OU-DOMINIO
```
**Exemplo:** `ssh santospap@192.168.1.100` ou `ssh santospap@meuservidor.com`
**3.2 - Digite a senha quando pedir**
**3.3 - Agora você está no servidor!**
---
## ✅ PASSO 4: Criar Pasta no Servidor
**4.1 - No servidor (via SSH), copie e cole:**
```bash
sudo mkdir -p /var/www/html/smart-agenda
```
**4.2 - Pressione Enter e digite a senha se pedir**
---
## ✅ PASSO 5: Copiar Arquivos (DO SEU MAC PARA O SERVIDOR)
**5.1 - Abra um NOVO terminal no seu Mac** (não feche o SSH)
**5.2 - Copie e cole este comando (ajuste o IP/domínio):**
```bash
scp -r /Users/230417/SmartAgendaMobile/web/dist/* santospap@SEU-IP-OU-DOMINIO:/var/www/html/smart-agenda/
```
**Exemplo:** `scp -r /Users/230417/SmartAgendaMobile/web/dist/* santospap@192.168.1.100:/var/www/html/smart-agenda/`
**5.3 - Digite a senha quando pedir**
**5.4 - Aguarde os arquivos serem copiados**
**5.5 - Verifique se os arquivos foram copiados (volte ao terminal SSH do servidor):**
```bash
ls -la /var/www/html/smart-agenda/
```
**Deve aparecer:** `index.html` e pasta `assets/`
---
## ✅ PASSO 6: Criar Arquivo .htaccess (NO SERVIDOR)
**6.1 - No servidor (via SSH), copie e cole:**
```bash
sudo nano /var/www/html/smart-agenda/.htaccess
```
**6.2 - Pressione Enter**
**6.3 - Cole este conteúdo (Ctrl+V ou botão direito > Paste):**
```apache
<IfModule mod_rewrite.c>
RewriteEngine On
RewriteBase /
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule . /index.html [L]
</IfModule>
```
**6.4 - Para salvar:**
- Pressione `Ctrl+O`
- Pressione `Enter`
- Pressione `Ctrl+X`
---
## ✅ PASSO 7: Configurar Permissões (NO SERVIDOR)
**7.1 - No servidor, copie e cole:**
```bash
sudo chown -R www-data:www-data /var/www/html/smart-agenda
```
**7.2 - Pressione Enter**
**7.3 - Copie e cole:**
```bash
sudo chmod -R 755 /var/www/html/smart-agenda
```
**7.4 - Pressione Enter**
**Nota:** Se usar CentOS/RHEL, use `apache:apache` em vez de `www-data:www-data`
---
## ✅ PASSO 8: Habilitar Módulo Rewrite (NO SERVIDOR)
**8.1 - Verifique qual sistema está usando:**
```bash
cat /etc/os-release
```
**8.2a - Se for Ubuntu/Debian, copie e cole:**
```bash
sudo a2enmod rewrite
sudo systemctl restart apache2
```
**8.2b - Se for CentOS/RHEL, copie e cole:**
```bash
sudo systemctl restart httpd
```
---
## ✅ PASSO 9: Verificar se Está Funcionando
**9.1 - No navegador do seu computador, acesse:**
```
http://SEU-IP-OU-DOMINIO/smart-agenda/
```
**Exemplo:** `http://192.168.1.100/smart-agenda/`
**9.2 - Deve aparecer a aplicação Smart Agenda!**
**9.3 - Teste as rotas:**
- `http://SEU-IP/smart-agenda/login`
- `http://SEU-IP/smart-agenda/explorar`
---
## ✅ PASSO 10: Configurar Domínio (OPCIONAL)
Se quiser usar um domínio (ex: `smartagenda.com`) em vez do IP:
**10.1 - No servidor, copie e cole (Ubuntu/Debian):**
```bash
sudo nano /etc/apache2/sites-available/smart-agenda.conf
```
**10.2 - Cole este conteúdo (ajuste o domínio):**
```apache
<VirtualHost *:80>
ServerName seu-dominio.com
ServerAlias www.seu-dominio.com
DocumentRoot /var/www/html/smart-agenda
<Directory /var/www/html/smart-agenda>
Options -Indexes +FollowSymLinks
AllowOverride All
Require all granted
</Directory>
ErrorLog ${APACHE_LOG_DIR}/smart-agenda-error.log
CustomLog ${APACHE_LOG_DIR}/smart-agenda-access.log combined
</VirtualHost>
```
**10.3 - Salve:** `Ctrl+O`, `Enter`, `Ctrl+X`
**10.4 - Ative o site:**
```bash
sudo a2ensite smart-agenda.conf
sudo systemctl reload apache2
```
---
## 🔧 SE ALGO DER ERRADO
### Problema: Página em branco
**No servidor, execute:**
```bash
sudo chmod -R 755 /var/www/html/smart-agenda
sudo chown -R www-data:www-data /var/www/html/smart-agenda
```
### Problema: Rotas não funcionam (404)
**No servidor, execute:**
```bash
sudo a2enmod rewrite
sudo systemctl restart apache2
ls -la /var/www/html/smart-agenda/.htaccess
```
### Problema: Assets não carregam
**No servidor, verifique:**
```bash
ls -la /var/www/html/smart-agenda/
ls -la /var/www/html/smart-agenda/assets/
```
**Deve ter arquivos CSS e JS na pasta assets/**
### Ver logs de erro:
**Ubuntu/Debian:**
```bash
sudo tail -f /var/log/apache2/error.log
```
**CentOS/RHEL:**
```bash
sudo tail -f /var/log/httpd/error_log
```
---
## ✅ CHECKLIST FINAL
- [ ] Build criado no Mac (`npm run build`)
- [ ] Arquivos copiados para o servidor
- [ ] Arquivo `.htaccess` criado
- [ ] Permissões configuradas
- [ ] Módulo rewrite habilitado
- [ ] Apache reiniciado
- [ ] Aplicação acessível no navegador
- [ ] Rotas funcionando
---
## 🎉 PRONTO!
Sua aplicação está publicada! Acesse `http://SEU-IP/smart-agenda/` no navegador.

288
web/PASSO_A_PASSO_APACHE.md Normal file
View File

@@ -0,0 +1,288 @@
# 📋 Passo a Passo - Publicar no Apache
Guia simples e direto para publicar a aplicação Smart Agenda no Apache.
---
## ⚠️ IMPORTANTE: Onde Executar os Comandos
- **Passos 1 e 2:** No seu **COMPUTADOR LOCAL** (onde está o código)
- **Passos 3 em diante:** No **SERVIDOR** (via SSH)
---
## ✅ Passo 1: Criar o Build (NO SEU COMPUTADOR)
**⚠️ Execute isto no seu computador, não no servidor!**
Abra o terminal no seu computador e vá até a pasta do projeto:
```bash
cd /Users/230417/SmartAgendaMobile/web
npm run build
```
**O que acontece:** Cria a pasta `dist/` com os arquivos otimizados para produção.
**Tempo estimado:** 1-2 minutos
**Verifique se a pasta foi criada:**
```bash
ls -la dist/
```
Deve ver arquivos como `index.html` e uma pasta `assets/`.
---
## ✅ Passo 2: Verificar o Build Localmente (Opcional - NO SEU COMPUTADOR)
**⚠️ Ainda no seu computador!**
Antes de enviar para o servidor, teste localmente:
```bash
npm run preview
```
Acesse `http://localhost:4173` no navegador para verificar se está tudo funcionando.
**Depois, pressione `Ctrl+C` para parar o servidor.**
---
## ✅ Passo 3: Conectar ao Servidor
Conecte-se ao seu servidor Apache via SSH:
```bash
ssh usuario@seu-servidor.com
```
**Substitua:**
- `usuario` pelo seu usuário do servidor
- `seu-servidor.com` pelo IP ou domínio do servidor
---
## ✅ Passo 4: Criar Diretório no Servidor
No servidor, crie a pasta onde a aplicação ficará:
```bash
sudo mkdir -p /var/www/html/smart-agenda
```
---
## ✅ Passo 5: Copiar Arquivos para o Servidor
**⚠️ IMPORTANTE: Execute isto no SEU COMPUTADOR, não no servidor!**
**Opção A - Via SCP (Recomendado - Do seu computador):**
No seu computador, abra um terminal e execute:
```bash
cd /Users/230417/SmartAgendaMobile/web
scp -r dist/* santospap@seu-servidor.com:/var/www/html/smart-agenda/
```
**Substitua `seu-servidor.com` pelo IP ou domínio do seu servidor.**
**Opção B - Via FTP/SFTP (FileZilla, WinSCP, etc.):**
1. No seu computador, abra o cliente FTP (FileZilla, WinSCP, etc.)
2. Conecte-se ao servidor
3. Navegue até `/var/www/html/` no servidor
4. Crie a pasta `smart-agenda` se não existir
5. No seu computador, navegue até `/Users/230417/SmartAgendaMobile/web/dist/`
6. Faça upload de **TODOS** os arquivos e pastas da pasta `dist/` para `/var/www/html/smart-agenda/` no servidor
**Opção C - Se já tiver os arquivos no servidor:**
Se por algum motivo já tiver os arquivos em outro lugar no servidor:
```bash
sudo cp -r /caminho/para/arquivos/* /var/www/html/smart-agenda/
```
---
## ✅ Passo 6: Criar Arquivo .htaccess
No servidor, crie o arquivo `.htaccess`:
```bash
sudo nano /var/www/html/smart-agenda/.htaccess
```
**Cole este conteúdo:**
```apache
<IfModule mod_rewrite.c>
RewriteEngine On
RewriteBase /
# Não reescrever arquivos e diretórios existentes
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
# Redirecionar tudo para index.html (SPA routing)
RewriteRule . /index.html [L]
</IfModule>
```
**Para salvar:** Pressione `Ctrl+O`, depois `Enter`, depois `Ctrl+X`
---
## ✅ Passo 7: Configurar Permissões
No servidor, configure as permissões corretas:
```bash
sudo chown -R www-data:www-data /var/www/html/smart-agenda
sudo chmod -R 755 /var/www/html/smart-agenda
```
**Nota:** Se usar CentOS/RHEL, use `apache:apache` em vez de `www-data:www-data`
---
## ✅ Passo 8: Habilitar Módulo Rewrite (Se necessário)
No servidor, habilite o módulo rewrite do Apache:
**Ubuntu/Debian:**
```bash
sudo a2enmod rewrite
sudo systemctl restart apache2
```
**CentOS/RHEL:**
```bash
# Geralmente já vem habilitado, mas verifique:
sudo systemctl restart httpd
```
---
## ✅ Passo 9: Configurar Virtual Host (Opcional mas Recomendado)
Se quiser usar um domínio específico, configure um Virtual Host:
**Ubuntu/Debian:**
```bash
sudo nano /etc/apache2/sites-available/smart-agenda.conf
```
**CentOS/RHEL:**
```bash
sudo nano /etc/httpd/conf.d/smart-agenda.conf
```
**Cole este conteúdo (ajuste o domínio):**
```apache
<VirtualHost *:80>
ServerName seu-dominio.com
ServerAlias www.seu-dominio.com
DocumentRoot /var/www/html/smart-agenda
<Directory /var/www/html/smart-agenda>
Options -Indexes +FollowSymLinks
AllowOverride All
Require all granted
</Directory>
ErrorLog ${APACHE_LOG_DIR}/smart-agenda-error.log
CustomLog ${APACHE_LOG_DIR}/smart-agenda-access.log combined
</VirtualHost>
```
**Para Ubuntu/Debian, ative o site:**
```bash
sudo a2ensite smart-agenda.conf
sudo systemctl reload apache2
```
**Para CentOS/RHEL:**
```bash
sudo systemctl reload httpd
```
---
## ✅ Passo 10: Testar
1. Acesse no navegador:
- `http://seu-servidor/smart-agenda/` (se não configurou domínio)
- `http://seu-dominio.com` (se configurou domínio)
2. Teste as rotas:
- `/login`
- `/explorar`
- `/registo`
3. Verifique o console do navegador (F12) para erros
---
## 🔧 Troubleshooting Rápido
### Problema: Página em branco
```bash
# Verifique permissões
sudo chmod -R 755 /var/www/html/smart-agenda
sudo chown -R www-data:www-data /var/www/html/smart-agenda
```
### Problema: Rotas não funcionam (404)
```bash
# Verifique se o módulo rewrite está habilitado
sudo a2enmod rewrite
sudo systemctl restart apache2
# Verifique se o .htaccess existe
ls -la /var/www/html/smart-agenda/.htaccess
```
### Problema: Assets não carregam
```bash
# Verifique se todos os arquivos foram copiados
ls -la /var/www/html/smart-agenda/
# Deve ter pelo menos:
# - index.html
# - assets/ (pasta)
```
### Ver logs de erro:
```bash
# Ubuntu/Debian
sudo tail -f /var/log/apache2/error.log
# CentOS/RHEL
sudo tail -f /var/log/httpd/error_log
```
---
## 📝 Checklist Final
- [ ] Build criado (`npm run build`)
- [ ] Arquivos copiados para `/var/www/html/smart-agenda/`
- [ ] Arquivo `.htaccess` criado
- [ ] Permissões configuradas
- [ ] Módulo rewrite habilitado
- [ ] Apache reiniciado
- [ ] Aplicação acessível no navegador
- [ ] Rotas funcionando corretamente
---
## 🎉 Pronto!
Sua aplicação está publicada! Se tiver problemas, verifique os logs do Apache.

439
web/PUBLICAR_SERVIDOR.md Normal file
View File

@@ -0,0 +1,439 @@
# 🚀 Como Publicar a Aplicação Web no Servidor
Este guia explica como fazer o build e publicar a aplicação web Smart Agenda em um servidor de produção.
## 📦 Build de Produção
### 1. Criar o Build
Na pasta `web`, execute:
```bash
cd web
npm run build
```
Isso criará uma pasta `dist/` com os arquivos otimizados para produção.
### 2. Verificar o Build Localmente
Antes de publicar, pode testar o build localmente:
```bash
npm run preview
```
Isso servirá o build em `http://localhost:4173` (porta padrão do Vite preview).
## 🌐 Opções de Publicação
### Opção 1: Servidor Estático (Nginx, Apache, etc.)
#### Nginx
1. Copie o conteúdo da pasta `dist/` para o diretório do servidor web:
```bash
cp -r web/dist/* /var/www/smart-agenda/
```
2. Configure o Nginx para servir os arquivos estáticos:
```nginx
server {
listen 80;
server_name seu-dominio.com;
root /var/www/smart-agenda;
index index.html;
location / {
try_files $uri $uri/ /index.html;
}
}
```
3. Reinicie o Nginx:
```bash
sudo systemctl restart nginx
```
#### Apache (Instruções Detalhadas)
##### Passo 1: Criar o Build
Na pasta do projeto:
```bash
cd web
npm run build
```
Isso criará a pasta `dist/` com os arquivos otimizados.
##### Passo 2: Copiar Arquivos para o Servidor
**Opção A - Via SCP (se o servidor for remoto):**
```bash
scp -r web/dist/* usuario@seu-servidor.com:/var/www/html/smart-agenda/
```
**Opção B - Via FTP/SFTP:**
- Conecte-se ao servidor via FTP/SFTP
- Navegue até `/var/www/html/` (ou o diretório configurado)
- Crie a pasta `smart-agenda` se não existir
- Faça upload de todos os arquivos da pasta `web/dist/`
**Opção C - No próprio servidor:**
```bash
# Se já estiver no servidor
cp -r /caminho/para/projeto/web/dist/* /var/www/html/smart-agenda/
```
##### Passo 3: Criar arquivo .htaccess
Crie um arquivo `.htaccess` na pasta `/var/www/html/smart-agenda/`:
```apache
<IfModule mod_rewrite.c>
RewriteEngine On
RewriteBase /
# Não reescrever arquivos e diretórios existentes
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
# Redirecionar tudo para index.html (SPA routing)
RewriteRule . /index.html [L]
</IfModule>
# Habilitar compressão GZIP
<IfModule mod_deflate.c>
AddOutputFilterByType DEFLATE text/html text/plain text/xml text/css text/javascript application/javascript application/json
</IfModule>
# Cache de arquivos estáticos
<IfModule mod_expires.c>
ExpiresActive On
ExpiresByType image/jpg "access plus 1 year"
ExpiresByType image/jpeg "access plus 1 year"
ExpiresByType image/gif "access plus 1 year"
ExpiresByType image/png "access plus 1 year"
ExpiresByType text/css "access plus 1 month"
ExpiresByType application/javascript "access plus 1 month"
ExpiresByType application/pdf "access plus 1 month"
ExpiresByType text/html "access plus 0 seconds"
</IfModule>
```
##### Passo 4: Configurar Virtual Host (Opcional mas Recomendado)
Crie ou edite o arquivo de configuração do Apache:
**Ubuntu/Debian:**
```bash
sudo nano /etc/apache2/sites-available/smart-agenda.conf
```
**CentOS/RHEL:**
```bash
sudo nano /etc/httpd/conf.d/smart-agenda.conf
```
Adicione a configuração:
```apache
<VirtualHost *:80>
ServerName seu-dominio.com
ServerAlias www.seu-dominio.com
DocumentRoot /var/www/html/smart-agenda
<Directory /var/www/html/smart-agenda>
Options -Indexes +FollowSymLinks
AllowOverride All
Require all granted
# Permitir arquivo .htaccess
AllowOverride FileInfo
</Directory>
# Logs
ErrorLog ${APACHE_LOG_DIR}/smart-agenda-error.log
CustomLog ${APACHE_LOG_DIR}/smart-agenda-access.log combined
</VirtualHost>
```
**Para Ubuntu/Debian, ative o site:**
```bash
sudo a2ensite smart-agenda.conf
sudo systemctl reload apache2
```
**Para CentOS/RHEL:**
```bash
sudo systemctl reload httpd
```
##### Passo 5: Habilitar Módulos Necessários
```bash
# Ubuntu/Debian
sudo a2enmod rewrite
sudo a2enmod headers
sudo systemctl restart apache2
# CentOS/RHEL
# Os módulos geralmente já vêm habilitados
sudo systemctl restart httpd
```
##### Passo 6: Verificar Permissões
```bash
# Definir proprietário correto (ajuste conforme necessário)
sudo chown -R www-data:www-data /var/www/html/smart-agenda
# Ou se usar outro usuário:
sudo chown -R apache:apache /var/www/html/smart-agenda
# Definir permissões
sudo chmod -R 755 /var/www/html/smart-agenda
```
##### Passo 7: Testar
1. Acesse `http://seu-servidor/smart-agenda/` ou `http://seu-dominio.com`
2. Verifique se a aplicação carrega
3. Teste as rotas (ex: `/login`, `/explorar`)
4. Verifique o console do navegador para erros
##### Configuração HTTPS (SSL/TLS) - Recomendado
Para produção, configure HTTPS usando Let's Encrypt:
```bash
# Instalar Certbot
sudo apt install certbot python3-certbot-apache # Ubuntu/Debian
sudo yum install certbot python3-certbot-apache # CentOS/RHEL
# Obter certificado
sudo certbot --apache -d seu-dominio.com -d www.seu-dominio.com
```
##### Troubleshooting Apache
**Problema: Rotas não funcionam (404)**
- Verifique se o módulo `rewrite` está habilitado
- Confirme que `AllowOverride All` está no VirtualHost
- Verifique se o `.htaccess` está no lugar correto
**Problema: Permissão negada**
```bash
sudo chmod -R 755 /var/www/html/smart-agenda
sudo chown -R www-data:www-data /var/www/html/smart-agenda
```
**Problema: Assets não carregam**
- Verifique o caminho base no `vite.config.ts`
- Confirme que todos os arquivos foram copiados
- Verifique as permissões dos arquivos
**Ver logs de erro:**
```bash
# Ubuntu/Debian
sudo tail -f /var/log/apache2/error.log
# CentOS/RHEL
sudo tail -f /var/log/httpd/error_log
```
### Opção 2: Vercel (Recomendado - Grátis)
1. Instale a CLI da Vercel:
```bash
npm i -g vercel
```
2. Na pasta `web`, execute:
```bash
vercel
```
3. Siga as instruções no terminal para configurar o projeto.
4. Para deploy automático, conecte o repositório GitHub na dashboard da Vercel.
### Opção 3: Netlify (Grátis)
1. Instale a CLI do Netlify:
```bash
npm i -g netlify-cli
```
2. Na pasta `web`, execute:
```bash
netlify deploy --prod --dir=dist
```
3. Ou arraste a pasta `dist/` para o site do Netlify.
### Opção 4: GitHub Pages
1. Instale o plugin do Vite:
```bash
npm install --save-dev vite-plugin-gh-pages
```
2. Atualize `vite.config.ts`:
```typescript
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import ghPages from 'vite-plugin-gh-pages';
export default defineConfig({
plugins: [react(), ghPages()],
base: '/SmartAgendaMobile/', // Nome do repositório
server: {
port: 5173,
host: '0.0.0.0',
strictPort: false,
},
});
```
3. Adicione script no `package.json`:
```json
{
"scripts": {
"deploy": "npm run build && gh-pages -d dist"
}
}
```
4. Execute:
```bash
npm run deploy
```
### Opção 5: Servidor Node.js com Express
1. Instale as dependências:
```bash
npm install express
```
2. Crie um arquivo `server.js` na pasta `web`:
```javascript
const express = require('express');
const path = require('path');
const app = express();
app.use(express.static(path.join(__dirname, 'dist')));
app.get('*', (req, res) => {
res.sendFile(path.join(__dirname, 'dist', 'index.html'));
});
const port = process.env.PORT || 3000;
app.listen(port, () => {
console.log(`Servidor rodando na porta ${port}`);
});
```
3. Adicione ao `package.json`:
```json
{
"scripts": {
"start:prod": "node server.js"
}
}
```
4. Execute:
```bash
npm run build
npm run start:prod
```
### Opção 6: Docker
1. Crie um `Dockerfile` na pasta `web`:
```dockerfile
FROM node:18-alpine as build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM nginx:alpine
COPY --from=build /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
```
2. Crie `nginx.conf`:
```nginx
server {
listen 80;
root /usr/share/nginx/html;
index index.html;
location / {
try_files $uri $uri/ /index.html;
}
}
```
3. Build e execute:
```bash
docker build -t smart-agenda-web .
docker run -p 80:80 smart-agenda-web
```
## ⚙️ Configurações Importantes
### Variáveis de Ambiente
Se precisar de variáveis de ambiente, crie um arquivo `.env.production`:
```env
VITE_API_URL=https://api.seudominio.com
VITE_APP_NAME=Smart Agenda
```
### Base Path
Se a aplicação não estiver na raiz do domínio, configure o `base` no `vite.config.ts`:
```typescript
export default defineConfig({
base: '/smart-agenda/',
// ...
});
```
## 🔍 Verificações Pós-Deploy
1. ✅ Verifique se todos os assets carregam corretamente
2. ✅ Teste todas as rotas (SPA routing)
3. ✅ Verifique o console do navegador para erros
4. ✅ Teste em diferentes navegadores
5. ✅ Verifique responsividade mobile
## 📝 Notas
- O build cria arquivos estáticos (HTML, CSS, JS)
- Não é necessário Node.js em produção (exceto se usar servidor Express)
- Certifique-se de que o servidor está configurado para servir `index.html` em todas as rotas (SPA routing)
- Para HTTPS, configure SSL/TLS no servidor
## 🆘 Problemas Comuns
### Rotas não funcionam após refresh
- Configure o servidor para redirecionar todas as rotas para `index.html`
### Assets não carregam
- Verifique o `base` path no `vite.config.ts`
- Certifique-se de que os arquivos estão no diretório correto
### Erro 404
- Configure o servidor para servir `index.html` em todas as rotas

View File

@@ -34,7 +34,12 @@ import {
ChevronDown,
UserPlus,
Globe,
TrendingUp,
HelpCircle,
MessageCircle,
Settings,
Store,
Clock,
ArrowRight,
} from 'lucide-react';
const periods: Record<string, (date: Date) => boolean> = {
@@ -107,34 +112,39 @@ export default function Dashboard() {
// Agendamentos concluídos (histórico)
const completedAppointments = allShopAppointments.filter((a) => a.status === 'concluido');
// Estatísticas para lista de marcações
const todayAppointments = appointments.filter((a) => {
// Estatísticas para lista de marcações (do dia selecionado)
const selectedDateAppointments = appointments.filter((a) => {
if (a.shopId !== shop.id) return false;
const aptDate = new Date(a.date.replace(' ', 'T'));
const today = new Date();
return (
aptDate.getDate() === today.getDate() &&
aptDate.getMonth() === today.getMonth() &&
aptDate.getFullYear() === today.getFullYear()
aptDate.getDate() === selectedDate.getDate() &&
aptDate.getMonth() === selectedDate.getMonth() &&
aptDate.getFullYear() === selectedDate.getFullYear()
);
});
const totalBookingsToday = todayAppointments.filter((a) => includeCancelled || a.status !== 'cancelado').length;
const totalBookingsToday = selectedDateAppointments.filter((a) => includeCancelled || a.status !== 'cancelado').length;
const newClientsToday = useMemo(() => {
const clientIds = new Set(todayAppointments.map((a) => a.customerId));
const clientIds = new Set(selectedDateAppointments.map((a) => a.customerId));
return clientIds.size;
}, [todayAppointments]);
const onlineBookingsToday = todayAppointments.filter((a) => a.status !== 'cancelado').length;
}, [selectedDateAppointments]);
const onlineBookingsToday = selectedDateAppointments.filter((a) => a.status !== 'cancelado').length;
const occupancyRate = useMemo(() => {
// Calcular ocupação baseada em slots disponíveis (8h-18h = 20 slots de 30min)
const totalSlots = 20;
const bookedSlots = todayAppointments.filter((a) => a.status !== 'cancelado').length;
const bookedSlots = selectedDateAppointments.filter((a) => a.status !== 'cancelado').length;
return Math.round((bookedSlots / totalSlots) * 100);
}, [todayAppointments]);
}, [selectedDateAppointments]);
// Comparação com semana passada (simplificado - sempre 0% por enquanto)
const comparisonPercent = 0;
// Filtrar agendamentos para lista
const filteredAppointments = useMemo(() => {
let filtered = shopAppointments;
let filtered = selectedDateAppointments;
if (!includeCancelled) {
filtered = filtered.filter((a) => a.status !== 'cancelado');
@@ -157,7 +167,8 @@ export default function Dashboard() {
}
return filtered;
}, [shopAppointments, includeCancelled, searchQuery, shop.services, shop.barbers, users]);
}, [selectedDateAppointments, includeCancelled, searchQuery, shop.services, shop.barbers, users]);
// Pedidos apenas com produtos (não serviços)
const shopOrders = orders.filter(
(o) => o.shopId === shop.id && periodMatch(new Date(o.createdAt)) && o.items.some((item) => item.type === 'product')
@@ -295,109 +306,238 @@ export default function Dashboard() {
{/* Tab Content */}
{activeTab === 'overview' && (
<div className="space-y-6">
{/* Stats Cards */}
<div className="grid md:grid-cols-4 gap-4">
<Card className="p-5 bg-gradient-to-br from-amber-50 to-white">
<div className="flex items-center justify-between mb-2">
<div className="p-2 bg-amber-500 rounded-lg text-white">
<TrendingUp size={20} />
{/* Saudação */}
<div className="space-y-1">
<h1 className="text-2xl font-bold text-slate-900">
{new Date().toLocaleDateString('pt-PT', { weekday: 'long', day: 'numeric', month: 'long' })}
</h1>
<p className="text-slate-600">
{(() => {
const hour = new Date().getHours();
if (hour < 12) return 'Bom dia';
if (hour < 18) return 'Boa tarde';
return 'Boa noite';
})()}, {user?.name}
</p>
</div>
{/* Stats Cards Principais */}
<div className="grid md:grid-cols-3 gap-4">
<Card className="p-6">
<div className="flex items-center justify-between mb-3">
<div className="p-3 bg-indigo-500 rounded-lg text-white">
<Calendar size={24} />
</div>
<Badge color="amber" variant="soft">Período</Badge>
</div>
<p className="text-sm text-slate-600 mb-1">Faturamento</p>
<p className="text-2xl font-bold text-amber-700">{currency(totalRevenue)}</p>
<p className="text-sm text-slate-600 mb-1">Total de reservas</p>
<p className="text-3xl font-bold text-slate-900">{allShopAppointments.length}</p>
<p className="text-xs text-slate-500 mt-2">Reservas da plataforma: {allShopAppointments.length}</p>
</Card>
<Card className="p-5">
<div className="flex items-center justify-between mb-2">
<div className="p-2 bg-blue-500 rounded-lg text-white">
<Calendar size={20} />
<Card className="p-6">
<div className="flex items-center justify-between mb-3">
<div className="p-3 bg-blue-500 rounded-lg text-white">
<Globe size={24} />
</div>
<Badge color="amber" variant="soft">{pendingAppts}</Badge>
</div>
<p className="text-sm text-slate-600 mb-1">Pendentes</p>
<p className="text-2xl font-bold text-slate-900">{pendingAppts}</p>
<p className="text-sm text-slate-600 mb-1">Reservas online</p>
<p className="text-3xl font-bold text-slate-900">{allShopAppointments.length}</p>
<p className="text-xs text-slate-500 mt-2">Marcações feitas pela plataforma</p>
</Card>
<Card className="p-5">
<div className="flex items-center justify-between mb-2">
<div className="p-2 bg-emerald-500 rounded-lg text-white">
<Calendar size={20} />
<Card className="p-6">
<div className="flex items-center justify-between mb-3">
<div className="p-3 bg-green-500 rounded-lg text-white">
<UserPlus size={24} />
</div>
<Badge color="green" variant="soft">{confirmedAppts}</Badge>
</div>
<p className="text-sm text-slate-600 mb-1">Confirmados</p>
<p className="text-2xl font-bold text-slate-900">{confirmedAppts}</p>
</Card>
<Card className={`p-5 ${lowStock.length > 0 ? 'bg-amber-50 border-amber-200' : ''}`}>
<div className="flex items-center justify-between mb-2">
<div className={`p-2 rounded-lg text-white ${lowStock.length > 0 ? 'bg-amber-500' : 'bg-slate-500'}`}>
<AlertTriangle size={20} />
</div>
{lowStock.length > 0 && <Badge color="amber" variant="solid">{lowStock.length}</Badge>}
</div>
<p className="text-sm text-slate-600 mb-1">Stock baixo</p>
<p className={`text-2xl font-bold ${lowStock.length > 0 ? 'text-amber-700' : 'text-slate-900'}`}>{lowStock.length}</p>
<p className="text-sm text-slate-600 mb-1">Novos clientes</p>
<p className="text-3xl font-bold text-slate-900">
{new Set(allShopAppointments.map(a => a.customerId)).size}
</p>
<p className="text-xs text-slate-500 mt-2">Clientes únicos</p>
</Card>
</div>
{/* Charts */}
<div className="grid md:grid-cols-2 gap-6">
<Card className="p-6">
<h3 className="text-lg font-bold text-slate-900 mb-4">Serviços vs Produtos</h3>
<div className="h-64">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={comparisonData}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="name" />
<Tooltip />
<Bar dataKey="value" fill="#f59e0b" radius={[6, 6, 0, 0]} />
</BarChart>
</ResponsiveContainer>
</div>
</Card>
<div className="space-y-4">
<Card className="p-5">
<h3 className="text-base font-bold text-slate-900 mb-3">Top 5 Serviços</h3>
<div className="space-y-2">
{topServices.length > 0 ? (
topServices.map((s, idx) => (
<div key={s.name} className="flex items-center justify-between text-sm">
<div className="flex items-center gap-2">
<span className="text-slate-400 font-bold">#{idx + 1}</span>
<span className="text-slate-700">{s.name}</span>
</div>
<Badge color="amber">{s.qty} vendas</Badge>
{/* Layout em duas colunas */}
<div className="grid md:grid-cols-3 gap-6">
{/* Coluna Principal - Esquerda */}
<div className="md:col-span-2 space-y-6">
{/* Reservas de Hoje */}
<Card className="p-6">
<div className="flex items-center gap-3 mb-4">
<div className="p-2 bg-indigo-100 rounded-lg">
<Search size={20} className="text-indigo-600" />
</div>
<div>
<h3 className="text-lg font-bold text-slate-900">Reservas de hoje</h3>
<p className="text-sm text-slate-600">Verá aqui as reservas de hoje assim que chegarem</p>
</div>
</div>
{(() => {
const today = new Date();
const todayAppts = allShopAppointments.filter(a => {
const aptDate = new Date(a.date.replace(' ', 'T'));
return aptDate.toDateString() === today.toDateString();
});
if (todayAppts.length === 0) {
return (
<div className="text-center py-8">
<Calendar size={48} className="mx-auto text-slate-300 mb-3" />
<p className="text-slate-600 font-medium mb-2">Sem reservas hoje</p>
<Button variant="outline" size="sm" onClick={() => setActiveTab('appointments')}>
Ir para o calendário
<ArrowRight size={16} className="ml-2" />
</Button>
</div>
))
) : (
<p className="text-sm text-slate-500">Sem vendas no período</p>
)}
);
}
return (
<div className="space-y-2">
{todayAppts.slice(0, 3).map(a => {
const svc = shop.services.find(s => s.id === a.serviceId);
const barber = shop.barbers.find(b => b.id === a.barberId);
const customer = users.find(u => u.id === a.customerId);
const aptDate = new Date(a.date.replace(' ', 'T'));
const timeStr = aptDate.toLocaleTimeString('pt-PT', { hour: '2-digit', minute: '2-digit' });
return (
<div key={a.id} className="flex items-center justify-between p-3 border border-slate-200 rounded-lg hover:bg-slate-50">
<div className="flex-1">
<p className="font-semibold text-slate-900">{customer?.name || 'Cliente'}</p>
<p className="text-sm text-slate-600">{timeStr} · {svc?.name || 'Serviço'}</p>
</div>
<Badge color={a.status === 'pendente' ? 'amber' : a.status === 'confirmado' ? 'green' : 'red'}>
{a.status}
</Badge>
</div>
);
})}
{todayAppts.length > 3 && (
<Button variant="outline" size="sm" className="w-full" onClick={() => setActiveTab('appointments')}>
Ver todas ({todayAppts.length})
</Button>
)}
</div>
);
})()}
</Card>
</div>
{/* Coluna Lateral - Direita */}
<div className="space-y-6">
{/* Ajuda */}
<Card className="p-6">
<h3 className="text-lg font-bold text-slate-900 mb-4">Tem alguma pergunta?</h3>
<div className="space-y-3">
<button className="w-full flex items-center gap-3 p-3 border border-slate-200 rounded-lg hover:bg-slate-50 transition-colors text-left">
<div className="p-2 bg-indigo-100 rounded-lg">
<HelpCircle size={18} className="text-indigo-600" />
</div>
<span className="font-medium text-slate-900">Centro de ajuda</span>
</button>
<button className="w-full flex items-center gap-3 p-3 border border-slate-200 rounded-lg hover:bg-slate-50 transition-colors text-left">
<div className="p-2 bg-blue-100 rounded-lg">
<MessageCircle size={18} className="text-blue-600" />
</div>
<span className="font-medium text-slate-900">Contacte-nos</span>
</button>
</div>
</Card>
<Card className="p-5">
<h3 className="text-base font-bold text-slate-900 mb-3">Top 5 Produtos</h3>
<div className="space-y-2">
{topProducts.length > 0 ? (
topProducts.map((p, idx) => (
<div key={p.name} className="flex items-center justify-between text-sm">
<div className="flex items-center gap-2">
<span className="text-slate-400 font-bold">#{idx + 1}</span>
<span className="text-slate-700">{p.name}</span>
</div>
<Badge color="amber">{p.qty} vendas</Badge>
</div>
))
) : (
<p className="text-sm text-slate-500">Sem vendas no período</p>
)}
{/* Atalhos */}
<Card className="p-6">
<h3 className="text-lg font-bold text-slate-900 mb-4">Atalhos</h3>
<div className="grid grid-cols-2 gap-3">
<button
onClick={() => setActiveTab('services')}
className="flex flex-col items-center gap-2 p-4 border border-slate-200 rounded-lg hover:bg-slate-50 transition-colors"
>
<div className="p-2 bg-indigo-100 rounded-lg">
<Scissors size={20} className="text-indigo-600" />
</div>
<span className="text-sm font-medium text-slate-900">Serviços</span>
</button>
<button
onClick={() => setActiveTab('appointments')}
className="flex flex-col items-center gap-2 p-4 border border-slate-200 rounded-lg hover:bg-slate-50 transition-colors"
>
<div className="p-2 bg-blue-100 rounded-lg">
<Users size={20} className="text-blue-600" />
</div>
<span className="text-sm font-medium text-slate-900">Clientes</span>
</button>
<button
onClick={() => setActiveTab('appointments')}
className="flex flex-col items-center gap-2 p-4 border border-slate-200 rounded-lg hover:bg-slate-50 transition-colors"
>
<div className="p-2 bg-green-100 rounded-lg">
<Calendar size={20} className="text-green-600" />
</div>
<span className="text-sm font-medium text-slate-900">Calendário</span>
</button>
<button className="flex flex-col items-center gap-2 p-4 border border-slate-200 rounded-lg hover:bg-slate-50 transition-colors">
<div className="p-2 bg-purple-100 rounded-lg">
<Settings size={20} className="text-purple-600" />
</div>
<span className="text-sm font-medium text-slate-900">Definições</span>
</button>
</div>
</Card>
{/* Próximos Agendamentos */}
<Card className="p-6">
<h3 className="text-lg font-bold text-slate-900 mb-4">Seguinte</h3>
{(() => {
const upcoming = allShopAppointments
.filter(a => {
const aptDate = new Date(a.date.replace(' ', 'T'));
return aptDate > new Date() && a.status !== 'cancelado';
})
.sort((a, b) => {
const dateA = new Date(a.date.replace(' ', 'T'));
const dateB = new Date(b.date.replace(' ', 'T'));
return dateA.getTime() - dateB.getTime();
})
.slice(0, 3);
if (upcoming.length === 0) {
return (
<p className="text-sm text-slate-500 text-center py-4">Sem agendamentos futuros</p>
);
}
return (
<div className="space-y-3">
{upcoming.map(a => {
const svc = shop.services.find(s => s.id === a.serviceId);
const barber = shop.barbers.find(b => b.id === a.barberId);
const customer = users.find(u => u.id === a.customerId);
const aptDate = new Date(a.date.replace(' ', 'T'));
const timeStr = aptDate.toLocaleTimeString('pt-PT', { hour: '2-digit', minute: '2-digit' });
return (
<div key={a.id} className="flex items-start gap-3 p-3 border border-slate-200 rounded-lg">
<div className="p-1.5 bg-indigo-100 rounded">
<Clock size={14} className="text-indigo-600" />
</div>
<div className="flex-1 min-w-0">
<p className="font-semibold text-slate-900 truncate">{customer?.name || 'Cliente'}</p>
<p className="text-sm text-slate-600">{timeStr}</p>
<p className="text-xs text-slate-500 truncate">{svc?.name || 'Serviço'}</p>
</div>
</div>
);
})}
</div>
);
})()}
</Card>
</div>
</div>
</div>
)}
@@ -442,10 +582,15 @@ export default function Dashboard() {
<div className="p-2 bg-indigo-500 rounded-lg text-white">
<Calendar size={20} />
</div>
<span className="text-xs text-slate-500">0%</span>
<span className={`text-xs ${comparisonPercent >= 0 ? 'text-green-600' : 'text-red-600'}`}>
{comparisonPercent >= 0 ? '+' : ''}{comparisonPercent}%
</span>
</div>
<p className="text-sm text-slate-600 mb-1">Total de marcações</p>
<p className="text-2xl font-bold text-slate-900">{totalBookingsToday}</p>
<p className="text-xs text-slate-500 mt-1">
Comparado com {totalBookingsToday} no mesmo dia da semana passada
</p>
</Card>
<Card className="p-5">
@@ -453,10 +598,15 @@ export default function Dashboard() {
<div className="p-2 bg-green-500 rounded-lg text-white">
<UserPlus size={20} />
</div>
<span className="text-xs text-slate-500">0%</span>
<span className={`text-xs ${comparisonPercent >= 0 ? 'text-green-600' : 'text-red-600'}`}>
{comparisonPercent >= 0 ? '+' : ''}{comparisonPercent}%
</span>
</div>
<p className="text-sm text-slate-600 mb-1">Novos clientes</p>
<p className="text-2xl font-bold text-slate-900">{newClientsToday}</p>
<p className="text-xs text-slate-500 mt-1">
Comparado com {newClientsToday} no mesmo dia da semana passada
</p>
</Card>
<Card className="p-5">
@@ -464,10 +614,15 @@ export default function Dashboard() {
<div className="p-2 bg-blue-500 rounded-lg text-white">
<Globe size={20} />
</div>
<span className="text-xs text-slate-500">0%</span>
<span className={`text-xs ${comparisonPercent >= 0 ? 'text-green-600' : 'text-red-600'}`}>
{comparisonPercent >= 0 ? '+' : ''}{comparisonPercent}%
</span>
</div>
<p className="text-sm text-slate-600 mb-1">Marcações online</p>
<p className="text-2xl font-bold text-slate-900">{onlineBookingsToday}</p>
<p className="text-xs text-slate-500 mt-1">
Comparado com {onlineBookingsToday} no mesmo dia da semana passada
</p>
</Card>
<Card className="p-5">
@@ -475,10 +630,15 @@ export default function Dashboard() {
<div className="p-2 bg-purple-500 rounded-lg text-white">
<TrendingUp size={20} />
</div>
<span className="text-xs text-slate-500">0%</span>
<span className={`text-xs ${comparisonPercent >= 0 ? 'text-green-600' : 'text-red-600'}`}>
{comparisonPercent >= 0 ? '+' : ''}{comparisonPercent}%
</span>
</div>
<p className="text-sm text-slate-600 mb-1">Ocupação</p>
<p className="text-2xl font-bold text-slate-900">{occupancyRate}%</p>
<p className="text-xs text-slate-500 mt-1">
Comparado com {occupancyRate}% no mesmo dia da semana passada
</p>
</Card>
</div>
@@ -489,7 +649,7 @@ export default function Dashboard() {
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400" size={18} />
<input
type="text"
placeholder="Pesquisar por cliente, serviço ou barbeiro..."
placeholder="Pesquisar por cliente"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full rounded-lg border border-slate-300 bg-white px-10 py-2.5 text-sm text-slate-900 placeholder:text-slate-400 focus:border-indigo-500 focus:outline-none focus:ring-2 focus:ring-indigo-500/30 transition-all"
@@ -528,7 +688,7 @@ export default function Dashboard() {
}}>
<ChevronLeft size={18} />
</Button>
<div className="text-sm font-medium text-slate-700">
<div className="text-sm font-medium text-slate-700 px-3">
{selectedDate.toLocaleDateString('pt-PT', {
weekday: 'long',
day: 'numeric',
@@ -630,7 +790,7 @@ export default function Dashboard() {
</div>
) : (
<div className="text-center py-16">
<Calendar size={64} className="mx-auto text-slate-300 mb-4" />
<Calendar size={64} className="mx-auto text-indigo-300 mb-4" />
<p className="text-lg font-semibold text-slate-900 mb-2">Sem reservas</p>
<p className="text-sm text-slate-600 max-w-md mx-auto">
Ambas as suas reservas online e manuais aparecerão aqui

View File

@@ -90,25 +90,25 @@ export default function Landing() {
</div>
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-6">
{[
{
icon: Calendar,
{[
{
icon: Calendar,
title: 'Agendamentos Inteligentes',
desc: 'Escolha serviço, barbeiro, data e horário com validação de slots em tempo real. Notificações automáticas.',
color: 'from-blue-500 to-blue-600'
},
{
icon: ShoppingBag,
title: 'Carrinho Inteligente',
color: 'from-blue-500 to-blue-600'
},
{
icon: ShoppingBag,
title: 'Carrinho Inteligente',
desc: 'Produtos e serviços agrupados por barbearia, checkout rápido e seguro. Histórico completo de compras.',
color: 'from-emerald-500 to-emerald-600'
},
{
icon: BarChart3,
title: 'Painel Completo',
color: 'from-emerald-500 to-emerald-600'
},
{
icon: BarChart3,
title: 'Painel Completo',
desc: 'Faturamento, agendamentos, pedidos e análises detalhadas. Tudo no controle da sua barbearia.',
color: 'from-purple-500 to-purple-600'
},
color: 'from-purple-500 to-purple-600'
},
{
icon: Users,
title: 'Gestão de Barbeiros',
@@ -127,17 +127,17 @@ export default function Landing() {
desc: 'Dados protegidos, pagamentos seguros e backup automático. Conformidade com LGPD.',
color: 'from-rose-500 to-rose-600'
},
].map((feature) => (
<Card key={feature.title} hover className="p-6 space-y-4 group">
<div className={`inline-flex p-3 rounded-xl bg-gradient-to-br ${feature.color} text-white shadow-lg group-hover:scale-110 transition-transform duration-200`}>
<feature.icon size={24} />
</div>
<div>
<h3 className="text-xl font-bold text-slate-900 mb-2">{feature.title}</h3>
<p className="text-sm text-slate-600 leading-relaxed">{feature.desc}</p>
</div>
</Card>
))}
].map((feature) => (
<Card key={feature.title} hover className="p-6 space-y-4 group">
<div className={`inline-flex p-3 rounded-xl bg-gradient-to-br ${feature.color} text-white shadow-lg group-hover:scale-110 transition-transform duration-200`}>
<feature.icon size={24} />
</div>
<div>
<h3 className="text-xl font-bold text-slate-900 mb-2">{feature.title}</h3>
<p className="text-sm text-slate-600 leading-relaxed">{feature.desc}</p>
</div>
</Card>
))}
</div>
</section>