diff --git a/.env.example b/.env.example
index 6fc8f0d..f357709 100644
--- a/.env.example
+++ b/.env.example
@@ -1,58 +1,34 @@
-# ===========================================
-# SmartZap - Next.js + Upstash Workflow v2.0
-# ===========================================
-
-# ----- SECURITY (Required for Production) -----
-# Generate with: openssl rand -base64 32
-# API Key for general API access
-SMARTZAP_API_KEY=szap_your_generated_api_key_here
-
-# Admin Key for sensitive operations (database, redeploy)
-SMARTZAP_ADMIN_KEY=szap_admin_your_admin_key_here
-
-# Allowed frontend origin for CORS (defaults to Vercel URL)
-FRONTEND_URL=https://your-app.vercel.app
-
-# ----- UPSTASH (Required for Production) -----
-# Get these from: https://console.upstash.com
-UPSTASH_REDIS_REST_URL=https://xxx.upstash.io
-UPSTASH_REDIS_REST_TOKEN=xxx
-
-# QStash is required for Upstash Workflow
-# Get from: https://console.upstash.com/qstash
-QSTASH_TOKEN=xxx
-QSTASH_CURRENT_SIGNING_KEY=xxx
-QSTASH_NEXT_SIGNING_KEY=xxx
-
-# ----- WHATSAPP API -----
-# From Meta Business Settings: https://business.facebook.com
-WHATSAPP_TOKEN=your_access_token
-WHATSAPP_PHONE_ID=your_phone_number_id
-WHATSAPP_BUSINESS_ACCOUNT_ID=your_business_account_id
-WHATSAPP_VERIFY_TOKEN=my_verify_token
-
-# ----- AI (Optional) -----
-# For AI-generated templates
-GEMINI_API_KEY=your_gemini_api_key
-
-# ----- DATABASE -----
-# Choose your database provider: 'turso' or 'supabase'
+# ------------------------------------------------------------
+# 🔐 SEGURANÇA / ADMIN (Recomendado)
+# ------------------------------------------------------------
+
+# Senha do painel (login). É a "senha mestra" do SmartZap.
+# - Local: defina aqui no .env.local
+# - Vercel: o wizard salva como variável de ambiente
+
+MASTER_PASSWORD=Vectra@179mr$$$HUB
+
+AUTH_SECRET=jaVBVCWDNboM0ctLGYlYpGkp3sFD/MukA3ytSaTe3E4=
+
+SMARTZAP_ADMIN_KEY=Vectra@179mr
+
+# (opcional mas recomendado)
+SMARTZAP_API_KEY=Vectra@179mr$$
+
+# ===== Banco (Supabase) =====
DATABASE_PROVIDER=supabase
+NEXT_PUBLIC_SUPABASE_URL=https://bfxovdgjgoijwkekbftl.supabase.co
+NEXT_PUBLIC_SUPABASE_ANON_KEY==eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImJmeG92ZGdqZ29pandrZWtiZnRsIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NjU3NTg5MDQsImV4cCI6MjA4MTMzNDkwNH0.aPbtNP_pAkmBGdSrdi4VqqvkxrVTTPivu2bo-F4ckZ8
+NEXT_PUBLIC_SUPABASE_PUBLISHABLE_DEFAULT_KEY=sb_secret_nFBQ7_EGAtnsSJF_x2jFXA_yCXbJQHU
+
+# ===== Upstash Redis (obrigatório p/ workflows) =====
+UPSTASH_REDIS_REST_URL=https://ready-sunbird-10099.upstash.io
+UPSTASH_REDIS_REST_TOKEN=ASdzAAIncDI3YWZjZTM0MmE1OWQ0ODJhYjgyNDZlYzU1Y2I5NWNlMXAyMTAwOTk
+
+# ===== QStash (obrigatório p/ workflows) =====
+QSTASH_URL="https://qstash.upstash.io"
+QSTASH_TOKEN=eyJVc2VySUQiOiIwZjNlNTY5Ny01NWYxLTQ0ZjItOWJiYS05OWNjOWU5OWM1MDMiLCJQYXNzd29yZCI6IjNiNmZlMDNmMDJhZDQ0ZGI5YzA2Y2VkYTU2Y2NjYjk1In0=
+QSTASH_CURRENT_SIGNING_KEY=sig_6F8RbNs8QXBjqzuN4h7SpXKgHMfX
+QSTASH_NEXT_SIGNING_KEY=sig_5rhRMUtxzFDeqdV1YkZhCLzayuP7
-# Turso (SQLite - legacy)
-TURSO_DATABASE_URL=libsql://your-database.turso.io
-TURSO_AUTH_TOKEN=your_turso_auth_token
-
-# Supabase (PostgreSQL - recommended)
-NEXT_PUBLIC_SUPABASE_URL=https://your-project.supabase.co
-NEXT_PUBLIC_SUPABASE_ANON_KEY=your_anon_key
-SUPABASE_SERVICE_ROLE_KEY=your_service_role_key
-
-# ----- Vercel / App URL -----
-# NEXT_PUBLIC_APP_URL is optional. If not set, the system will auto-detect:
-# 1. VERCEL_PROJECT_PRODUCTION_URL (stable production domain, auto-set by Vercel)
-# 2. VERCEL_URL (deployment-specific URL, auto-set by Vercel)
-#
-# Only set this manually if you have a custom domain:
-# NEXT_PUBLIC_APP_URL=https://your-custom-domain.com
diff --git a/.eslintignore b/.eslintignore
new file mode 100644
index 0000000..04999ab
--- /dev/null
+++ b/.eslintignore
@@ -0,0 +1,6 @@
+node_modules
+.next
+dist
+coverage
+.next/standalone
+.next/cache
diff --git a/.eslintrc.cjs b/.eslintrc.cjs
new file mode 100644
index 0000000..c655dad
--- /dev/null
+++ b/.eslintrc.cjs
@@ -0,0 +1,16 @@
+/** @type {import("eslint").Linter.Config} */
+module.exports = {
+ extends: ["next/core-web-vitals"],
+ parser: "@typescript-eslint/parser",
+ plugins: ["@typescript-eslint"],
+ parserOptions: {
+ ecmaVersion: "latest",
+ sourceType: "module",
+ project: ["./tsconfig.json"],
+ tsconfigRootDir: __dirname,
+ },
+ rules: {
+ "react/no-unescaped-entities": "off",
+ "@typescript-eslint/no-explicit-any": "off",
+ },
+};
diff --git a/.eslintrc.json b/.eslintrc.json
new file mode 100644
index 0000000..fc1dba1
--- /dev/null
+++ b/.eslintrc.json
@@ -0,0 +1,9 @@
+{
+ "extends": ["next/core-web-vitals"],
+ "plugins": ["@typescript-eslint"],
+ "parser": "@typescript-eslint/parser",
+ "rules": {
+ "react/no-unescaped-entities": "off",
+ "@typescript-eslint/no-explicit-any": "off"
+ }
+}
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index 0a986a1..8b18798 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -8,34 +8,41 @@ on:
jobs:
test:
- name: Tests & Type Check
+ name: Tests, Lint & Type Check
runs-on: ubuntu-latest
-
+
steps:
- name: Checkout
uses: actions/checkout@v4
-
+
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
-
+
- name: Install dependencies
run: npm ci
-
+
- name: Run type check
run: npx tsc --noEmit
-
+
- name: Run unit tests
- run: npm test -- --run
-
+ run: npm test -- --run --passWithNoTests
+
+ - name: Run lint
+ run: npm run lint
+
+ - name: Build
+ run: npm run build
+
- name: Upload test results
uses: actions/upload-artifact@v4
if: always()
with:
name: test-results
path: test-results/
+ if-no-files-found: ignore
retention-days: 7
# E2E tests are run locally before deploy
diff --git a/.gitignore b/.gitignore
index 4d8a23b..0bae916 100644
--- a/.gitignore
+++ b/.gitignore
@@ -51,3 +51,4 @@ coverage/
# Backups / Utility
tmp/
+.env*.local
diff --git a/SmartZap_API.postman_collection.json b/SmartZap_API.postman_collection.json
new file mode 100644
index 0000000..03115dd
--- /dev/null
+++ b/SmartZap_API.postman_collection.json
@@ -0,0 +1,486 @@
+{
+ "info": {
+ "name": "SmartZap API",
+ "description": "Collection completa para testar todos os endpoints da API SmartZap",
+ "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json"
+ },
+ "variable": [
+ {
+ "key": "baseUrl",
+ "value": "https://implacable-destiny-unflamboyantly.ngrok-free.dev",
+ "type": "string"
+ },
+ {
+ "key": "localUrl",
+ "value": "http://localhost:3000",
+ "type": "string"
+ }
+ ],
+ "item": [
+ {
+ "name": "Campanhas",
+ "item": [
+ {
+ "name": "Listar Todas as Campanhas",
+ "request": {
+ "method": "GET",
+ "header": [],
+ "url": {
+ "raw": "{{baseUrl}}/api/campaigns",
+ "host": ["{{baseUrl}}"],
+ "path": ["api", "campaigns"]
+ }
+ },
+ "response": []
+ },
+ {
+ "name": "Criar Nova Campanha",
+ "request": {
+ "method": "POST",
+ "header": [
+ {
+ "key": "Content-Type",
+ "value": "application/json"
+ }
+ ],
+ "body": {
+ "mode": "raw",
+ "raw": "{\n \"name\": \"Promoção de Natal 2025\",\n \"templateName\": \"promo_natal\",\n \"recipients\": 5,\n \"scheduledAt\": \"2025-12-25T10:00:00Z\",\n \"contacts\": [\n {\n \"name\": \"João Silva\",\n \"phone\": \"+5511999999999\"\n },\n {\n \"name\": \"Maria Santos\",\n \"phone\": \"+5511988888888\"\n }\n ],\n \"templateVariables\": [\"João\", \"25/12\"]\n}"
+ },
+ "url": {
+ "raw": "{{baseUrl}}/api/campaigns",
+ "host": ["{{baseUrl}}"],
+ "path": ["api", "campaigns"]
+ }
+ },
+ "response": []
+ },
+ {
+ "name": "Buscar Campanha por ID",
+ "request": {
+ "method": "GET",
+ "header": [],
+ "url": {
+ "raw": "{{baseUrl}}/api/campaigns/:id",
+ "host": ["{{baseUrl}}"],
+ "path": ["api", "campaigns", ":id"],
+ "variable": [
+ {
+ "key": "id",
+ "value": "seu-campaign-id-aqui"
+ }
+ ]
+ }
+ },
+ "response": []
+ },
+ {
+ "name": "Atualizar Campanha",
+ "request": {
+ "method": "PUT",
+ "header": [
+ {
+ "key": "Content-Type",
+ "value": "application/json"
+ }
+ ],
+ "body": {
+ "mode": "raw",
+ "raw": "{\n \"name\": \"Promoção Atualizada\",\n \"status\": \"PAUSED\"\n}"
+ },
+ "url": {
+ "raw": "{{baseUrl}}/api/campaigns/:id",
+ "host": ["{{baseUrl}}"],
+ "path": ["api", "campaigns", ":id"],
+ "variable": [
+ {
+ "key": "id",
+ "value": "seu-campaign-id-aqui"
+ }
+ ]
+ }
+ },
+ "response": []
+ },
+ {
+ "name": "Deletar Campanha",
+ "request": {
+ "method": "DELETE",
+ "header": [],
+ "url": {
+ "raw": "{{baseUrl}}/api/campaigns/:id",
+ "host": ["{{baseUrl}}"],
+ "path": ["api", "campaigns", ":id"],
+ "variable": [
+ {
+ "key": "id",
+ "value": "seu-campaign-id-aqui"
+ }
+ ]
+ }
+ },
+ "response": []
+ }
+ ]
+ },
+ {
+ "name": "Contatos",
+ "item": [
+ {
+ "name": "Listar Todos os Contatos",
+ "request": {
+ "method": "GET",
+ "header": [],
+ "url": {
+ "raw": "{{baseUrl}}/api/contacts",
+ "host": ["{{baseUrl}}"],
+ "path": ["api", "contacts"]
+ }
+ },
+ "response": []
+ },
+ {
+ "name": "Adicionar Contato",
+ "request": {
+ "method": "POST",
+ "header": [
+ {
+ "key": "Content-Type",
+ "value": "application/json"
+ }
+ ],
+ "body": {
+ "mode": "raw",
+ "raw": "{\n \"name\": \"Maria Santos\",\n \"phone\": \"+5511988887777\",\n \"tags\": [\"cliente\", \"vip\"]\n}"
+ },
+ "url": {
+ "raw": "{{baseUrl}}/api/contacts",
+ "host": ["{{baseUrl}}"],
+ "path": ["api", "contacts"]
+ }
+ },
+ "response": []
+ },
+ {
+ "name": "Importar Contatos CSV",
+ "request": {
+ "method": "POST",
+ "header": [
+ {
+ "key": "Content-Type",
+ "value": "application/json"
+ }
+ ],
+ "body": {
+ "mode": "raw",
+ "raw": "{\n \"contacts\": [\n { \"name\": \"João Silva\", \"phone\": \"+5511999999999\" },\n { \"name\": \"Maria Santos\", \"phone\": \"+5511988888888\" },\n { \"name\": \"Pedro Oliveira\", \"phone\": \"+5511977777777\" }\n ]\n}"
+ },
+ "url": {
+ "raw": "{{baseUrl}}/api/contacts/import",
+ "host": ["{{baseUrl}}"],
+ "path": ["api", "contacts", "import"]
+ }
+ },
+ "response": []
+ },
+ {
+ "name": "Deletar Múltiplos Contatos",
+ "request": {
+ "method": "DELETE",
+ "header": [
+ {
+ "key": "Content-Type",
+ "value": "application/json"
+ }
+ ],
+ "body": {
+ "mode": "raw",
+ "raw": "{\n \"contactIds\": [\"id1\", \"id2\", \"id3\"]\n}"
+ },
+ "url": {
+ "raw": "{{baseUrl}}/api/contacts",
+ "host": ["{{baseUrl}}"],
+ "path": ["api", "contacts"]
+ }
+ },
+ "response": []
+ }
+ ]
+ },
+ {
+ "name": "Webhook WhatsApp",
+ "item": [
+ {
+ "name": "Verificação Webhook (Meta)",
+ "request": {
+ "method": "GET",
+ "header": [],
+ "url": {
+ "raw": "{{baseUrl}}/api/webhook?hub.mode=subscribe&hub.verify_token=seu-verify-token&hub.challenge=12345",
+ "host": ["{{baseUrl}}"],
+ "path": ["api", "webhook"],
+ "query": [
+ {
+ "key": "hub.mode",
+ "value": "subscribe"
+ },
+ {
+ "key": "hub.verify_token",
+ "value": "seu-verify-token",
+ "description": "Token configurado no Meta App"
+ },
+ {
+ "key": "hub.challenge",
+ "value": "12345",
+ "description": "Código enviado pela Meta"
+ }
+ ]
+ }
+ },
+ "response": []
+ },
+ {
+ "name": "Receber Mensagem WhatsApp",
+ "request": {
+ "method": "POST",
+ "header": [
+ {
+ "key": "Content-Type",
+ "value": "application/json"
+ }
+ ],
+ "body": {
+ "mode": "raw",
+ "raw": "{\n \"object\": \"whatsapp_business_account\",\n \"entry\": [\n {\n \"id\": \"WHATSAPP_BUSINESS_ACCOUNT_ID\",\n \"changes\": [\n {\n \"value\": {\n \"messaging_product\": \"whatsapp\",\n \"metadata\": {\n \"display_phone_number\": \"5511999999999\",\n \"phone_number_id\": \"PHONE_NUMBER_ID\"\n },\n \"contacts\": [\n {\n \"profile\": {\n \"name\": \"João Silva\"\n },\n \"wa_id\": \"5511988888888\"\n }\n ],\n \"messages\": [\n {\n \"from\": \"5511988888888\",\n \"id\": \"wamid.HBgNNTUxMTk4ODg4ODg4OA==\",\n \"timestamp\": \"1702910400\",\n \"type\": \"text\",\n \"text\": {\n \"body\": \"Olá, gostaria de saber mais sobre os produtos\"\n }\n }\n ]\n },\n \"field\": \"messages\"\n }\n ]\n }\n ]\n}"
+ },
+ "url": {
+ "raw": "{{baseUrl}}/api/webhook",
+ "host": ["{{baseUrl}}"],
+ "path": ["api", "webhook"]
+ }
+ },
+ "response": []
+ },
+ {
+ "name": "Status de Entrega (Webhook)",
+ "request": {
+ "method": "POST",
+ "header": [
+ {
+ "key": "Content-Type",
+ "value": "application/json"
+ }
+ ],
+ "body": {
+ "mode": "raw",
+ "raw": "{\n \"object\": \"whatsapp_business_account\",\n \"entry\": [\n {\n \"id\": \"WHATSAPP_BUSINESS_ACCOUNT_ID\",\n \"changes\": [\n {\n \"value\": {\n \"messaging_product\": \"whatsapp\",\n \"metadata\": {\n \"display_phone_number\": \"5511999999999\",\n \"phone_number_id\": \"PHONE_NUMBER_ID\"\n },\n \"statuses\": [\n {\n \"id\": \"wamid.HBgNNTUxMTk4ODg4ODg4OA==\",\n \"status\": \"delivered\",\n \"timestamp\": \"1702910400\",\n \"recipient_id\": \"5511988888888\"\n }\n ]\n },\n \"field\": \"messages\"\n }\n ]\n }\n ]\n}"
+ },
+ "url": {
+ "raw": "{{baseUrl}}/api/webhook",
+ "host": ["{{baseUrl}}"],
+ "path": ["api", "webhook"]
+ }
+ },
+ "response": []
+ }
+ ]
+ },
+ {
+ "name": "Dashboard",
+ "item": [
+ {
+ "name": "Estatísticas do Dashboard",
+ "request": {
+ "method": "GET",
+ "header": [],
+ "url": {
+ "raw": "{{baseUrl}}/api/dashboard",
+ "host": ["{{baseUrl}}"],
+ "path": ["api", "dashboard"]
+ }
+ },
+ "response": []
+ }
+ ]
+ },
+ {
+ "name": "Templates",
+ "item": [
+ {
+ "name": "Listar Templates",
+ "request": {
+ "method": "GET",
+ "header": [],
+ "url": {
+ "raw": "{{baseUrl}}/api/templates",
+ "host": ["{{baseUrl}}"],
+ "path": ["api", "templates"]
+ }
+ },
+ "response": []
+ },
+ {
+ "name": "Buscar Templates da Meta",
+ "request": {
+ "method": "GET",
+ "header": [],
+ "url": {
+ "raw": "{{baseUrl}}/api/templates/fetch",
+ "host": ["{{baseUrl}}"],
+ "path": ["api", "templates", "fetch"]
+ }
+ },
+ "response": []
+ }
+ ]
+ },
+ {
+ "name": "Agentes de IA",
+ "item": [
+ {
+ "name": "Listar Agentes",
+ "request": {
+ "method": "GET",
+ "header": [],
+ "url": {
+ "raw": "{{baseUrl}}/api/ai-agents",
+ "host": ["{{baseUrl}}"],
+ "path": ["api", "ai-agents"]
+ }
+ },
+ "response": []
+ },
+ {
+ "name": "Criar Agente de IA",
+ "request": {
+ "method": "POST",
+ "header": [
+ {
+ "key": "Content-Type",
+ "value": "application/json"
+ }
+ ],
+ "body": {
+ "mode": "raw",
+ "raw": "{\n \"name\": \"Assistente de Vendas\",\n \"systemPrompt\": \"Você é um assistente de vendas que ajuda clientes a escolher produtos.\",\n \"model\": \"gemini-pro\",\n \"temperature\": 0.7\n}"
+ },
+ "url": {
+ "raw": "{{baseUrl}}/api/ai-agents",
+ "host": ["{{baseUrl}}"],
+ "path": ["api", "ai-agents"]
+ }
+ },
+ "response": []
+ }
+ ]
+ },
+ {
+ "name": "Conversas",
+ "item": [
+ {
+ "name": "Listar Conversas",
+ "request": {
+ "method": "GET",
+ "header": [],
+ "url": {
+ "raw": "{{baseUrl}}/api/conversations",
+ "host": ["{{baseUrl}}"],
+ "path": ["api", "conversations"]
+ }
+ },
+ "response": []
+ },
+ {
+ "name": "Buscar Conversa por ID",
+ "request": {
+ "method": "GET",
+ "header": [],
+ "url": {
+ "raw": "{{baseUrl}}/api/conversations/:id",
+ "host": ["{{baseUrl}}"],
+ "path": ["api", "conversations", ":id"],
+ "variable": [
+ {
+ "key": "id",
+ "value": "seu-conversation-id-aqui"
+ }
+ ]
+ }
+ },
+ "response": []
+ }
+ ]
+ },
+ {
+ "name": "Configurações",
+ "item": [
+ {
+ "name": "Obter Configurações WhatsApp",
+ "request": {
+ "method": "GET",
+ "header": [],
+ "url": {
+ "raw": "{{baseUrl}}/api/settings/whatsapp",
+ "host": ["{{baseUrl}}"],
+ "path": ["api", "settings", "whatsapp"]
+ }
+ },
+ "response": []
+ },
+ {
+ "name": "Atualizar Configurações WhatsApp",
+ "request": {
+ "method": "POST",
+ "header": [
+ {
+ "key": "Content-Type",
+ "value": "application/json"
+ }
+ ],
+ "body": {
+ "mode": "raw",
+ "raw": "{\n \"phoneNumberId\": \"seu-phone-number-id\",\n \"businessAccountId\": \"seu-business-account-id\",\n \"accessToken\": \"seu-access-token\",\n \"displayPhoneNumber\": \"+5511999999999\",\n \"verifiedName\": \"Sua Empresa\"\n}"
+ },
+ "url": {
+ "raw": "{{baseUrl}}/api/settings/whatsapp",
+ "host": ["{{baseUrl}}"],
+ "path": ["api", "settings", "whatsapp"]
+ }
+ },
+ "response": []
+ }
+ ]
+ },
+ {
+ "name": "System Health",
+ "item": [
+ {
+ "name": "Health Check",
+ "request": {
+ "method": "GET",
+ "header": [],
+ "url": {
+ "raw": "{{baseUrl}}/api/health",
+ "host": ["{{baseUrl}}"],
+ "path": ["api", "health"]
+ }
+ },
+ "response": []
+ },
+ {
+ "name": "Status do Sistema",
+ "request": {
+ "method": "GET",
+ "header": [],
+ "url": {
+ "raw": "{{baseUrl}}/api/system/status",
+ "host": ["{{baseUrl}}"],
+ "path": ["api", "system", "status"]
+ }
+ },
+ "response": []
+ }
+ ]
+ }
+ ]
+}
diff --git a/app/(auth)/setup/wizard/page.tsx b/app/(auth)/setup/wizard/page.tsx
index 49cd6bb..c70345d 100644
--- a/app/(auth)/setup/wizard/page.tsx
+++ b/app/(auth)/setup/wizard/page.tsx
@@ -859,7 +859,7 @@ function WizardContent() {
className="w-full bg-zinc-800 border border-zinc-700 rounded-xl px-4 py-3 text-white placeholder:text-zinc-500 focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:border-transparent font-mono text-sm"
/>
- Use o "Connection Pooler" (porta 6543) para melhor compatibilidade
+ Use o "Connection Pooler" (porta 6543) para melhor compatibilidade
diff --git a/app/(dashboard)/DashboardShell.tsx b/app/(dashboard)/DashboardShell.tsx
index b8b059a..d63f9a5 100644
--- a/app/(dashboard)/DashboardShell.tsx
+++ b/app/(dashboard)/DashboardShell.tsx
@@ -385,7 +385,7 @@ const OnboardingOverlay = ({
Complete os passos acima na ordem para liberar o acesso ao sistema.
- Após configurar cada serviço no Vercel, clique em "Verificar novamente".
+ Após configurar cada serviço no Vercel, clique em "Verificar novamente".
)
diff --git a/app/(dashboard)/loading.tsx b/app/(dashboard)/loading.tsx
index 8bf5a08..5293864 100644
--- a/app/(dashboard)/loading.tsx
+++ b/app/(dashboard)/loading.tsx
@@ -1,5 +1,7 @@
import { Skeleton } from "@/components/ui/skeleton"
+const CHART_BAR_HEIGHTS = [20, 55, 38, 72, 45, 60, 30, 80, 50, 65, 40, 70]
+
export default function DashboardLoading() {
return (
@@ -35,7 +37,11 @@ export default function DashboardLoading() {
{Array.from({ length: 12 }).map((_, i) => (
-
+
))}
diff --git a/app/api/account/limits/route.ts b/app/api/account/limits/route.ts
index 2f3449f..bd8e5be 100644
--- a/app/api/account/limits/route.ts
+++ b/app/api/account/limits/route.ts
@@ -12,15 +12,19 @@ const TIER_LIMITS: Record = {
}
// Shared logic to fetch limits from Meta API
-async function fetchLimitsFromMeta(phoneNumberId: string, accessToken: string) {
+async function fetchLimitsFromMeta(
+ phoneNumberId: string,
+ businessAccountId: string,
+ accessToken: string
+) {
// Parallel fetch for throughput/quality and messaging tier
const [throughputResponse, tierResponse] = await Promise.all([
fetch(
- `https://graph.facebook.com/v24.0/${phoneNumberId}?fields=throughput,quality_score`,
+ `https://graph.facebook.com/v24.0/${phoneNumberId}?fields=throughput,quality_rating,quality_score`,
{ headers: { 'Authorization': `Bearer ${accessToken}` } }
),
fetch(
- `https://graph.facebook.com/v24.0/${phoneNumberId}?fields=whatsapp_business_manager_messaging_limit`,
+ `https://graph.facebook.com/v24.0/${businessAccountId}?fields=whatsapp_business_manager_messaging_limit`,
{ headers: { 'Authorization': `Bearer ${accessToken}` } }
),
])
@@ -41,7 +45,9 @@ async function fetchLimitsFromMeta(phoneNumberId: string, accessToken: string) {
const throughputLevel = throughputData.throughput?.level === 'high' ? 'HIGH' : 'STANDARD'
// Parse quality score
- const rawQuality = throughputData.quality_score?.score?.toUpperCase()
+ const rawQuality =
+ throughputData.quality_rating?.toUpperCase?.() ||
+ throughputData.quality_score?.score?.toUpperCase?.()
const qualityScore = ['GREEN', 'YELLOW', 'RED'].includes(rawQuality) ? rawQuality : 'UNKNOWN'
// Parse messaging tier
@@ -71,7 +77,7 @@ async function fetchLimitsFromMeta(phoneNumberId: string, accessToken: string) {
export async function GET() {
const credentials = await getWhatsAppCredentials()
- if (!credentials?.phoneNumberId || !credentials?.accessToken) {
+ if (!credentials?.phoneNumberId || !credentials?.businessAccountId || !credentials?.accessToken) {
return NextResponse.json({
error: 'NO_CREDENTIALS',
message: 'Credenciais do WhatsApp não configuradas. Configure em Ajustes.'
@@ -79,7 +85,11 @@ export async function GET() {
}
try {
- const limits = await fetchLimitsFromMeta(credentials.phoneNumberId, credentials.accessToken)
+ const limits = await fetchLimitsFromMeta(
+ credentials.phoneNumberId,
+ credentials.businessAccountId,
+ credentials.accessToken
+ )
return NextResponse.json(limits)
} catch (error) {
console.error('❌ Error fetching account limits:', error)
@@ -94,6 +104,7 @@ export async function GET() {
// POST /api/account/limits - Fetch limits (with optional body credentials, fallback to Redis)
export async function POST(request: NextRequest) {
let phoneNumberId: string | undefined
+ let businessAccountId: string | undefined
let accessToken: string | undefined
// Try to get from request body first
@@ -102,6 +113,7 @@ export async function POST(request: NextRequest) {
// Only use if they look like real credentials (not masked)
if (body.phoneNumberId && body.accessToken && !body.accessToken.includes('***')) {
phoneNumberId = body.phoneNumberId
+ businessAccountId = body.businessAccountId
accessToken = body.accessToken
}
} catch {
@@ -109,15 +121,16 @@ export async function POST(request: NextRequest) {
}
// Fallback to Redis credentials if not provided
- if (!phoneNumberId || !accessToken) {
+ if (!phoneNumberId || !businessAccountId || !accessToken) {
const credentials = await getWhatsAppCredentials()
if (credentials) {
phoneNumberId = credentials.phoneNumberId
+ businessAccountId = credentials.businessAccountId
accessToken = credentials.accessToken
}
}
- if (!phoneNumberId || !accessToken) {
+ if (!phoneNumberId || !businessAccountId || !accessToken) {
return NextResponse.json({
error: 'NO_CREDENTIALS',
message: 'Credenciais do WhatsApp não configuradas. Configure em Ajustes.'
@@ -125,7 +138,7 @@ export async function POST(request: NextRequest) {
}
try {
- const limits = await fetchLimitsFromMeta(phoneNumberId, accessToken)
+ const limits = await fetchLimitsFromMeta(phoneNumberId, businessAccountId, accessToken)
return NextResponse.json(limits)
} catch (error) {
console.error('❌ Error fetching account limits:', error)
diff --git a/app/api/phone-numbers/[phoneNumberId]/webhook/override/route.ts b/app/api/phone-numbers/[phoneNumberId]/webhook/override/route.ts
index 05a73c4..301c254 100644
--- a/app/api/phone-numbers/[phoneNumberId]/webhook/override/route.ts
+++ b/app/api/phone-numbers/[phoneNumberId]/webhook/override/route.ts
@@ -1,6 +1,6 @@
import { NextRequest, NextResponse } from 'next/server';
import { getWhatsAppCredentials } from '@/lib/whatsapp-credentials';
-import { redis, isRedisAvailable } from '@/lib/redis';
+import { settingsDb } from '@/lib/supabase-db';
const META_API_VERSION = 'v21.0';
const META_API_BASE = `https://graph.facebook.com/${META_API_VERSION}`;
@@ -9,19 +9,26 @@ interface RouteContext {
params: Promise<{ phoneNumberId: string }>;
}
-// Get verify token from Redis (same logic as webhook endpoint)
+// Get or generate webhook verify token (Supabase settings preferred, env var fallback)
async function getVerifyToken(): Promise {
- if (isRedisAvailable() && redis) {
- const storedToken = await redis.get('webhook:verify_token');
+ try {
+ const storedToken = await settingsDb.get('webhook_verify_token');
if (storedToken) {
- return storedToken as string;
+ return storedToken;
}
- // Generate new UUID token and store in Redis
+
const newToken = crypto.randomUUID();
- await redis.set('webhook:verify_token', newToken);
+ await settingsDb.set('webhook_verify_token', newToken);
return newToken;
+ } catch {
+ if (process.env.WEBHOOK_VERIFY_TOKEN) {
+ return process.env.WEBHOOK_VERIFY_TOKEN.trim();
+ }
+ if (process.env.WHATSAPP_VERIFY_TOKEN) {
+ return process.env.WHATSAPP_VERIFY_TOKEN.trim();
+ }
+ return 'not-configured';
}
- return process.env.WEBHOOK_VERIFY_TOKEN || 'smartzap_verify_token';
}
/**
@@ -69,8 +76,8 @@ export async function POST(request: NextRequest, context: RouteContext) {
);
}
- // Get verify token from Redis (ensures consistency with webhook endpoint)
- const verifyTokenFromRedis = await getVerifyToken();
+ // Use the same verify token as the main webhook endpoint
+ const verifyToken = await getVerifyToken();
// Call Meta API to set webhook override on phone number
// Reference: https://developers.facebook.com/docs/whatsapp/cloud-api/webhooks/override
@@ -85,7 +92,7 @@ export async function POST(request: NextRequest, context: RouteContext) {
body: JSON.stringify({
webhook_configuration: {
override_callback_uri: callbackUrl,
- verify_token: verifyTokenFromRedis,
+ verify_token: verifyToken,
},
}),
}
diff --git a/app/api/settings/ai/route.ts b/app/api/settings/ai/route.ts
index 291293f..635cd82 100644
--- a/app/api/settings/ai/route.ts
+++ b/app/api/settings/ai/route.ts
@@ -36,7 +36,9 @@ async function validateApiKey(provider: string, apiKey: string): Promise<{ valid
await generateText({
model,
prompt: 'Hi',
- maxOutputTokens: 5,
+ // Some providers enforce a minimum output token limit (e.g. >= 16)
+ // Keep this low but valid to avoid false "invalid key" errors.
+ maxOutputTokens: 16,
})
return { valid: true }
diff --git a/app/api/settings/credentials/route.ts b/app/api/settings/credentials/route.ts
index a27d49e..9f0aa4b 100644
--- a/app/api/settings/credentials/route.ts
+++ b/app/api/settings/credentials/route.ts
@@ -14,9 +14,9 @@ interface WhatsAppCredentials {
// GET - Fetch credentials from env (without exposing full token)
export async function GET() {
try {
- const phoneNumberId = process.env.WHATSAPP_PHONE_ID
- const businessAccountId = process.env.WHATSAPP_BUSINESS_ACCOUNT_ID
- const accessToken = process.env.WHATSAPP_TOKEN
+ const phoneNumberId = process.env.WHATSAPP_PHONE_ID?.trim()
+ const businessAccountId = process.env.WHATSAPP_BUSINESS_ACCOUNT_ID?.trim()
+ const accessToken = process.env.WHATSAPP_TOKEN?.trim()
if (phoneNumberId && businessAccountId && accessToken) {
// Fetch display phone number from Meta API
@@ -67,7 +67,9 @@ export async function GET() {
export async function POST(request: NextRequest) {
try {
const body = await request.json()
- const { phoneNumberId, businessAccountId, accessToken } = body
+ const phoneNumberId = typeof body?.phoneNumberId === 'string' ? body.phoneNumberId.trim() : ''
+ const businessAccountId = typeof body?.businessAccountId === 'string' ? body.businessAccountId.trim() : ''
+ const accessToken = typeof body?.accessToken === 'string' ? body.accessToken.trim() : ''
if (!phoneNumberId || !businessAccountId || !accessToken) {
return NextResponse.json(
@@ -76,28 +78,55 @@ export async function POST(request: NextRequest) {
)
}
- // Validate token by making a test call to Meta API
- const testResponse = await fetch(
+ // Validate PHONE_NUMBER_ID (must be a WhatsApp phone number node)
+ const phoneResponse = await fetch(
`https://graph.facebook.com/v24.0/${phoneNumberId}?fields=display_phone_number,verified_name,quality_rating`,
- {
- headers: {
- 'Authorization': `Bearer ${accessToken}`,
- },
- }
+ { headers: { 'Authorization': `Bearer ${accessToken}` } }
)
- if (!testResponse.ok) {
- const error = await testResponse.json()
+ if (!phoneResponse.ok) {
+ const error = await phoneResponse.json().catch(() => ({}))
+ const message = error?.error?.message || 'Unknown error'
+
+ // Common pitfall: user pasted Business Account ID into Phone Number ID
+ if (typeof message === 'string' && message.includes('Tried accessing nonexisting field') && message.includes('WhatsAppBusinessAccount')) {
+ return NextResponse.json(
+ {
+ error: 'IDs do WhatsApp parecem estar trocados',
+ details: 'Você colocou o WhatsApp Business Account ID no campo Phone Number ID. O Phone Number ID é o ID do número (phone_number_id), não o WABA.',
+ },
+ { status: 400 }
+ )
+ }
+
return NextResponse.json(
{
- error: 'Invalid credentials - Meta API rejected the token',
- details: error.error?.message || 'Unknown error'
+ error: 'Credenciais inválidas - Meta API rejeitou a validação do Phone Number ID',
+ details: message,
},
{ status: 401 }
)
}
- const phoneData = await testResponse.json()
+ const phoneData = await phoneResponse.json()
+
+ // Validate BUSINESS_ACCOUNT_ID (must be a WhatsApp Business Account node)
+ const businessResponse = await fetch(
+ `https://graph.facebook.com/v24.0/${businessAccountId}/phone_numbers?fields=id&limit=1`,
+ { headers: { 'Authorization': `Bearer ${accessToken}` } }
+ )
+
+ if (!businessResponse.ok) {
+ const error = await businessResponse.json().catch(() => ({}))
+ const message = error?.error?.message || 'Unknown error'
+ return NextResponse.json(
+ {
+ error: 'Credenciais inválidas - Meta API rejeitou a validação do Business Account ID',
+ details: message,
+ },
+ { status: 401 }
+ )
+ }
// Note: Credentials are stored in Vercel env vars via the setup wizard
// This endpoint only validates them
diff --git a/app/api/setup/validate/route.ts b/app/api/setup/validate/route.ts
index 27f0f47..9d47eb2 100644
--- a/app/api/setup/validate/route.ts
+++ b/app/api/setup/validate/route.ts
@@ -206,29 +206,44 @@ async function validateWhatsApp(credentials: Record) {
}
try {
- // Test WhatsApp by getting phone number info
- const response = await fetch(
- `https://graph.facebook.com/v21.0/${phoneId}`,
- {
- headers: {
- 'Authorization': `Bearer ${token}`,
- },
- }
+ // 1) Validate phone number id (PHONE_NUMBER_ID)
+ const phoneRes = await fetch(
+ `https://graph.facebook.com/v24.0/${phoneId}?fields=display_phone_number,verified_name,quality_rating`,
+ { headers: { 'Authorization': `Bearer ${token}` } }
)
- if (!response.ok) {
- const error = await response.json()
- return NextResponse.json({
- valid: false,
- error: error.error?.message || 'Token ou Phone ID inválido'
- })
+ if (!phoneRes.ok) {
+ const error = await phoneRes.json().catch(() => ({}))
+ const message = error?.error?.message || 'Token ou Phone ID inválido'
+
+ // Friendly hint when user swapped IDs
+ if (typeof message === 'string' && message.includes('Tried accessing nonexisting field') && message.includes('WhatsAppBusinessAccount')) {
+ return NextResponse.json({
+ valid: false,
+ error: 'IDs parecem estar trocados: no campo Phone Number ID você colocou o Business Account ID (WABA).'
+ })
+ }
+
+ return NextResponse.json({ valid: false, error: message })
}
- const data = await response.json()
+ const phoneData = await phoneRes.json()
+
+ // 2) Validate business account id (WABA) can list phone numbers
+ const businessRes = await fetch(
+ `https://graph.facebook.com/v24.0/${businessId}/phone_numbers?fields=id&limit=1`,
+ { headers: { 'Authorization': `Bearer ${token}` } }
+ )
+
+ if (!businessRes.ok) {
+ const error = await businessRes.json().catch(() => ({}))
+ const message = error?.error?.message || 'Business Account ID inválido'
+ return NextResponse.json({ valid: false, error: message })
+ }
return NextResponse.json({
valid: true,
- message: `WhatsApp OK! (${data.verified_name || data.display_phone_number || 'Conectado'})`
+ message: `WhatsApp OK! (${phoneData.verified_name || phoneData.display_phone_number || 'Conectado'})`
})
} catch (error) {
console.error('WhatsApp validation error:', error)
diff --git a/app/api/webhook/info/route.ts b/app/api/webhook/info/route.ts
index eaec265..c158c53 100644
--- a/app/api/webhook/info/route.ts
+++ b/app/api/webhook/info/route.ts
@@ -20,6 +20,9 @@ async function getVerifyToken(): Promise {
if (process.env.WEBHOOK_VERIFY_TOKEN) {
return process.env.WEBHOOK_VERIFY_TOKEN.trim()
}
+ if (process.env.WHATSAPP_VERIFY_TOKEN) {
+ return process.env.WHATSAPP_VERIFY_TOKEN.trim()
+ }
return 'not-configured'
}
}
diff --git a/app/api/webhook/route.ts b/app/api/webhook/route.ts
index 5693f07..fa05025 100644
--- a/app/api/webhook/route.ts
+++ b/app/api/webhook/route.ts
@@ -48,6 +48,9 @@ async function getVerifyToken(): Promise {
if (process.env.WEBHOOK_VERIFY_TOKEN) {
return process.env.WEBHOOK_VERIFY_TOKEN.trim()
}
+ if (process.env.WHATSAPP_VERIFY_TOKEN) {
+ return process.env.WHATSAPP_VERIFY_TOKEN.trim()
+ }
return 'not-configured'
}
}
diff --git a/app/auth/callback/route.ts b/app/auth/callback/route.ts
new file mode 100644
index 0000000..4d8876a
--- /dev/null
+++ b/app/auth/callback/route.ts
@@ -0,0 +1,76 @@
+import { createClient } from '@/utils/supabase/server'
+import { NextResponse } from 'next/server'
+
+/**
+ * Auth Callback Route
+ *
+ * Handles Supabase Auth callbacks for:
+ * - Email confirmation
+ * - Password reset
+ * - Magic link login
+ * - OAuth callbacks
+ *
+ * Supabase sends tokens in different ways:
+ * 1. PKCE flow: ?code=xxx
+ * 2. Email links: ?token_hash=xxx&type=recovery
+ * 3. After verification: redirects with session in hash fragment
+ */
+export async function GET(request: Request) {
+ const { searchParams, origin } = new URL(request.url)
+ const code = searchParams.get('code')
+ const token_hash = searchParams.get('token_hash')
+ const type = searchParams.get('type')
+ const next = searchParams.get('next') ?? '/'
+ const error = searchParams.get('error')
+ const error_description = searchParams.get('error_description')
+
+ // If there's an error from Supabase
+ if (error) {
+ console.error('[auth/callback] Error:', error, error_description)
+ return NextResponse.redirect(`${origin}/login?error=${encodeURIComponent(error_description || error)}`)
+ }
+
+ // Handle PKCE code exchange
+ if (code) {
+ const supabase = await createClient()
+ const { error: exchangeError } = await supabase.auth.exchangeCodeForSession(code)
+
+ if (!exchangeError) {
+ // Redirect based on auth type
+ if (type === 'recovery') {
+ return NextResponse.redirect(`${origin}/auth/reset-password`)
+ }
+ return NextResponse.redirect(`${origin}${next}`)
+ }
+ console.error('[auth/callback] Code exchange error:', exchangeError)
+ }
+
+ // Handle token hash (email links)
+ if (token_hash && type) {
+ const supabase = await createClient()
+ const { error: verifyError } = await supabase.auth.verifyOtp({
+ token_hash,
+ type: type as 'recovery' | 'signup' | 'invite' | 'email',
+ })
+
+ if (!verifyError) {
+ if (type === 'recovery' || type === 'invite') {
+ return NextResponse.redirect(`${origin}/auth/reset-password`)
+ }
+ return NextResponse.redirect(`${origin}${next}`)
+ }
+ console.error('[auth/callback] OTP verify error:', verifyError)
+ }
+
+ // If we get here with no code/token, Supabase already processed the token
+ // and is redirecting with session info in hash fragment.
+ // The session should already be set, redirect to reset password page.
+ // Check if this is a recovery flow by looking at the redirect path
+ if (type === 'recovery' || type === 'invite') {
+ return NextResponse.redirect(`${origin}/auth/reset-password`)
+ }
+
+ // Default: redirect to reset-password since this callback is typically
+ // used after email verification for password reset
+ return NextResponse.redirect(`${origin}/auth/reset-password`)
+}
diff --git a/app/auth/reset-password/page.tsx b/app/auth/reset-password/page.tsx
new file mode 100644
index 0000000..3228ab7
--- /dev/null
+++ b/app/auth/reset-password/page.tsx
@@ -0,0 +1,179 @@
+'use client'
+
+/**
+ * Reset Password Page
+ *
+ * Allows users to set a new password after clicking reset link
+ */
+
+import { useState } from 'react'
+import { useRouter } from 'next/navigation'
+import { Lock, Eye, EyeOff, Check } from 'lucide-react'
+import { createClient } from '@/utils/supabase/client'
+
+export default function ResetPasswordPage() {
+ const router = useRouter()
+ const [password, setPassword] = useState('')
+ const [confirmPassword, setConfirmPassword] = useState('')
+ const [showPassword, setShowPassword] = useState(false)
+ const [showConfirmPassword, setShowConfirmPassword] = useState(false)
+ const [error, setError] = useState('')
+ const [success, setSuccess] = useState(false)
+ const [isLoading, setIsLoading] = useState(false)
+
+ const handleSubmit = async (e: React.FormEvent) => {
+ e.preventDefault()
+ setError('')
+
+ if (!password) {
+ setError('Digite a nova senha')
+ return
+ }
+
+ if (password.length < 6) {
+ setError('A senha deve ter pelo menos 6 caracteres')
+ return
+ }
+
+ if (password !== confirmPassword) {
+ setError('As senhas não coincidem')
+ return
+ }
+
+ setIsLoading(true)
+
+ try {
+ const supabase = createClient()
+ const { error } = await supabase.auth.updateUser({
+ password: password,
+ })
+
+ if (error) {
+ throw error
+ }
+
+ setSuccess(true)
+
+ // Redirect to login after 2 seconds
+ setTimeout(() => {
+ router.push('/login')
+ }, 2000)
+ } catch (err) {
+ setError(err instanceof Error ? err.message : 'Erro ao atualizar senha')
+ } finally {
+ setIsLoading(false)
+ }
+ }
+
+ if (success) {
+ return (
+
+
+
+
+
+
+
Senha alterada!
+
Redirecionando para o login...
+
+
+
+ )
+ }
+
+ return (
+
+
+ {/* Header */}
+
+
+ S
+
+
SmartZap
+
Defina sua nova senha
+
+
+ {/* Card */}
+
+
+ {/* Footer */}
+
+ SmartZap © {new Date().getFullYear()}
+
+
+
+ )
+}
diff --git a/app/layout.tsx b/app/layout.tsx
index a268b65..cbef18b 100644
--- a/app/layout.tsx
+++ b/app/layout.tsx
@@ -3,6 +3,7 @@ import { Inter } from 'next/font/google'
import './globals.css'
import { Providers } from './providers'
import { Toaster } from 'sonner'
+import { Analytics } from '@vercel/analytics/next'
const inter = Inter({
subsets: ['latin'],
@@ -26,6 +27,7 @@ export default function RootLayout({
{children}
+