Publish MetaAds Pro: AI-driven Meta Ads campaign management dashboard

Full app with AI campaign creation/copy/creative generation, dashboard,
campaign automation engine, Meta OAuth login, and a redesigned landing page.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Marcio Bevervanso 2026-06-07 19:30:33 -03:00
parent b955aee644
commit 234314d1c6
87 changed files with 13166 additions and 125 deletions

View file

@ -0,0 +1 @@
{"sessionId":"0c60e074-c6b2-4343-b24e-0fb9b7558d41","pid":2474,"procStart":"Sun Jun 7 11:38:33 2026","acquiredAt":1780834101623}

6
.gitignore vendored
View file

@ -33,6 +33,12 @@ yarn-error.log*
# env files (can opt-in for committing if needed)
.env*
# runtime settings — contains real Meta App ID/Secret in plaintext
/settings.json
# local scratch files
/Arquivo.zip
# vercel
.vercel

12
mcp-config.json Normal file
View file

@ -0,0 +1,12 @@
{
"mcpServers": {
"metaads-pro": {
"command": "node",
"args": ["./mcp-server/dist/index.js"],
"env": {
"META_ACCESS_TOKEN": "YOUR_LONG_LIVED_TOKEN",
"META_AD_ACCOUNT_ID": "act_XXXXXXXXX"
}
}
}
}

243
mcp-server/index.ts Normal file
View file

@ -0,0 +1,243 @@
#!/usr/bin/env node
/**
* MetaAds Pro MCP Server
*
* Exposes Meta Marketing API operations as MCP tools.
* Can be connected to Claude Desktop, Cursor, or any MCP client.
*
* Tools:
* - list_campaigns: Lists all active/paused campaigns
* - get_campaign_insights: Gets metrics for a specific campaign
* - get_account_overview: Gets account-level metrics summary
* - pause_campaign: Pauses a campaign
* - resume_campaign: Resumes a paused campaign
* - update_budget: Updates a campaign's daily budget
*/
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import {
CallToolRequestSchema,
ListToolsRequestSchema,
} from '@modelcontextprotocol/sdk/types.js';
// @ts-expect-error - CJS module
const bizSdk = await import('facebook-nodejs-business-sdk');
const accessToken = process.env.META_ACCESS_TOKEN;
const adAccountId = process.env.META_AD_ACCOUNT_ID;
if (!accessToken || !adAccountId) {
console.error('Error: META_ACCESS_TOKEN and META_AD_ACCOUNT_ID must be set');
process.exit(1);
}
// Init Meta API
Object.defineProperty(bizSdk.default.FacebookAdsApi, 'VERSION', { get: () => 'v20.0' });
bizSdk.default.FacebookAdsApi.init(accessToken);
const AdAccount = bizSdk.default.AdAccount;
const Campaign = bizSdk.default.Campaign;
// ---- MCP Server ----
const server = new Server(
{ name: 'metaads-pro', version: '1.0.0' },
{ capabilities: { tools: {} } }
);
// ---- Tool Definitions ----
server.setRequestHandler(ListToolsRequestSchema, async () => ({
tools: [
{
name: 'list_campaigns',
description: 'Lista todas as campanhas ativas e pausadas da conta de anúncios do Meta/Facebook',
inputSchema: {
type: 'object' as const,
properties: {},
required: [],
},
},
{
name: 'get_campaign_insights',
description: 'Busca métricas detalhadas (spend, CTR, CPC, CPM, leads, ROAS) de uma campanha específica',
inputSchema: {
type: 'object' as const,
properties: {
campaign_id: { type: 'string', description: 'ID da campanha' },
date_preset: {
type: 'string',
enum: ['today', 'yesterday', 'last_7d', 'last_14d', 'last_30d', 'this_month', 'lifetime'],
description: 'Período de tempo (padrão: last_30d)',
},
},
required: ['campaign_id'],
},
},
{
name: 'get_account_overview',
description: 'Retorna um resumo geral da conta: gasto total, leads, ROAS, CTR, CPC, CPM',
inputSchema: {
type: 'object' as const,
properties: {
date_preset: {
type: 'string',
enum: ['today', 'yesterday', 'last_7d', 'last_14d', 'last_30d', 'this_month', 'lifetime'],
description: 'Período de tempo (padrão: last_30d)',
},
},
required: [],
},
},
{
name: 'pause_campaign',
description: 'Pausa uma campanha ativa',
inputSchema: {
type: 'object' as const,
properties: {
campaign_id: { type: 'string', description: 'ID da campanha para pausar' },
},
required: ['campaign_id'],
},
},
{
name: 'resume_campaign',
description: 'Reativa uma campanha pausada',
inputSchema: {
type: 'object' as const,
properties: {
campaign_id: { type: 'string', description: 'ID da campanha para reativar' },
},
required: ['campaign_id'],
},
},
{
name: 'update_budget',
description: 'Altera o orçamento diário de uma campanha (valor em reais)',
inputSchema: {
type: 'object' as const,
properties: {
campaign_id: { type: 'string', description: 'ID da campanha' },
daily_budget_brl: { type: 'number', description: 'Novo orçamento diário em R$ (ex: 50.00)' },
},
required: ['campaign_id', 'daily_budget_brl'],
},
},
],
}));
// ---- Tool Handlers ----
server.setRequestHandler(CallToolRequestSchema, async (request) => {
const { name, arguments: args } = request.params;
try {
switch (name) {
case 'list_campaigns': {
const account = new AdAccount(adAccountId);
const campaigns = await account.getCampaigns(
['id', 'name', 'status', 'objective', 'daily_budget', 'created_time'],
{ limit: 100, filtering: [{ field: 'status', operator: 'IN', value: ['ACTIVE', 'PAUSED'] }] }
);
const data = campaigns.map((c: Record<string, unknown>) => ({
id: c.id,
name: c.name,
status: c.status,
objective: c.objective,
daily_budget: `R$ ${(parseInt(String(c.daily_budget || '0'), 10) / 100).toFixed(2)}`,
}));
return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
}
case 'get_campaign_insights': {
const { campaign_id, date_preset = 'last_30d' } = args as { campaign_id: string; date_preset?: string };
const campaign = new Campaign(campaign_id);
const insights = await campaign.getInsights(
['campaign_name', 'spend', 'ctr', 'cpc', 'cpm', 'impressions', 'clicks', 'actions', 'purchase_roas'],
{ date_preset, limit: 1 }
);
if (!insights || insights.length === 0) {
return { content: [{ type: 'text', text: 'Nenhum dado de insights encontrado para este período.' }] };
}
const i = insights[0];
const leads = i.actions?.find((a: { action_type: string }) => a.action_type === 'lead')?.value || '0';
const roas = i.purchase_roas?.[0]?.value || '0';
const summary = {
campaign: i.campaign_name,
spend: `R$ ${parseFloat(i.spend || '0').toFixed(2)}`,
impressions: parseInt(i.impressions || '0', 10),
clicks: parseInt(i.clicks || '0', 10),
ctr: `${parseFloat(i.ctr || '0').toFixed(2)}%`,
cpc: `R$ ${parseFloat(i.cpc || '0').toFixed(2)}`,
cpm: `R$ ${parseFloat(i.cpm || '0').toFixed(2)}`,
leads: parseInt(leads, 10),
roas: parseFloat(roas).toFixed(2) + 'x',
};
return { content: [{ type: 'text', text: JSON.stringify(summary, null, 2) }] };
}
case 'get_account_overview': {
const { date_preset = 'last_30d' } = args as { date_preset?: string };
const account = new AdAccount(adAccountId);
const insights = await account.getInsights(
['spend', 'ctr', 'cpc', 'cpm', 'impressions', 'clicks', 'actions', 'purchase_roas'],
{ date_preset, level: 'account', limit: 1 }
);
if (!insights || insights.length === 0) {
return { content: [{ type: 'text', text: 'Nenhum dado disponível para este período.' }] };
}
const i = insights[0];
const leads = i.actions?.find((a: { action_type: string }) => a.action_type === 'lead')?.value || '0';
const roas = i.purchase_roas?.[0]?.value || '0';
const summary = {
total_spend: `R$ ${parseFloat(i.spend || '0').toFixed(2)}`,
impressions: parseInt(i.impressions || '0', 10),
clicks: parseInt(i.clicks || '0', 10),
ctr: `${parseFloat(i.ctr || '0').toFixed(2)}%`,
cpc: `R$ ${parseFloat(i.cpc || '0').toFixed(2)}`,
cpm: `R$ ${parseFloat(i.cpm || '0').toFixed(2)}`,
leads: parseInt(leads, 10),
roas: parseFloat(roas).toFixed(2) + 'x',
};
return { content: [{ type: 'text', text: JSON.stringify(summary, null, 2) }] };
}
case 'pause_campaign': {
const { campaign_id } = args as { campaign_id: string };
const campaign = new Campaign(campaign_id);
await campaign.update([], { [Campaign.Fields.status]: 'PAUSED' });
return { content: [{ type: 'text', text: `✅ Campanha ${campaign_id} pausada com sucesso.` }] };
}
case 'resume_campaign': {
const { campaign_id } = args as { campaign_id: string };
const campaign = new Campaign(campaign_id);
await campaign.update([], { [Campaign.Fields.status]: 'ACTIVE' });
return { content: [{ type: 'text', text: `✅ Campanha ${campaign_id} reativada com sucesso.` }] };
}
case 'update_budget': {
const { campaign_id, daily_budget_brl } = args as { campaign_id: string; daily_budget_brl: number };
const budgetCents = Math.round(daily_budget_brl * 100);
const campaign = new Campaign(campaign_id);
await campaign.update([], { [Campaign.Fields.daily_budget]: budgetCents });
return {
content: [{ type: 'text', text: `✅ Orçamento da campanha ${campaign_id} atualizado para R$ ${daily_budget_brl.toFixed(2)}.` }],
};
}
default:
return { content: [{ type: 'text', text: `Tool "${name}" não encontrada.` }], isError: true };
}
} catch (error) {
const msg = error instanceof Error ? error.message : 'Unknown error';
return { content: [{ type: 'text', text: `❌ Erro: ${msg}` }], isError: true };
}
});
// ---- Start Server ----
async function main() {
const transport = new StdioServerTransport();
await server.connect(transport);
console.error('MetaAds Pro MCP Server running on stdio');
}
main().catch(console.error);

19
mcp-server/package.json Normal file
View file

@ -0,0 +1,19 @@
{
"name": "metaads-pro-mcp",
"version": "1.0.0",
"description": "MCP Server for Meta Ads Management",
"type": "module",
"main": "index.js",
"scripts": {
"build": "tsc",
"start": "node dist/index.js"
},
"dependencies": {
"@modelcontextprotocol/sdk": "^1.0.0",
"facebook-nodejs-business-sdk": "^24.0.1"
},
"devDependencies": {
"typescript": "^5.0.0",
"@types/node": "^20.0.0"
}
}

17
mcp-server/tsconfig.json Normal file
View file

@ -0,0 +1,17 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "ES2020",
"moduleResolution": "node",
"outDir": "./dist",
"rootDir": ".",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"declaration": true,
"resolveJsonModule": true
},
"include": ["*.ts"],
"exclude": ["node_modules", "dist"]
}

93
n8n-workflow.json Normal file
View file

@ -0,0 +1,93 @@
{
"name": "Gerador de Imagens (GPT-Image-2)",
"nodes": [
{
"parameters": {
"httpMethod": "POST",
"path": "generate-ad-image",
"responseMode": "lastNode",
"options": {}
},
"id": "webhook-node-1",
"name": "Webhook",
"type": "n8n-nodes-base.webhook",
"typeVersion": 1,
"position": [
250,
300
],
"webhookId": "ad-image-generator"
},
{
"parameters": {
"method": "POST",
"url": "https://api.openai.com/v1/images/generations",
"sendHeaders": true,
"headerParameters": {
"parameters": [
{
"name": "Authorization",
"value": "Bearer sk-proj-COLOQUE_SUA_CHAVE_AQUI"
},
{
"name": "Content-Type",
"value": "application/json"
}
]
},
"sendBody": true,
"specifyBody": "json",
"jsonBody": "={\n \"model\": \"gpt-image-2\",\n \"prompt\": \"{{ $json.body.prompt }}\",\n \"n\": 1,\n \"size\": \"{{ $json.body.dimensions || '1024x1024' }}\"\n}",
"options": {
"timeout": 120000
}
},
"id": "http-request-1",
"name": "OpenAI - Generate Image",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.1,
"position": [
450,
300
]
},
{
"parameters": {
"jsCode": "const response = $input.first().json;\n\n// Pega a URL ou o Base64 gerado pela OpenAI\nlet imageUrl = response.data?.[0]?.url;\nif (!imageUrl && response.data?.[0]?.b64_json) {\n imageUrl = `data:image/png;base64,${response.data[0].b64_json}`;\n}\n\n// Devolve o JSON no formato que o Next.js espera\nreturn {\n url: imageUrl,\n revised_prompt: response.data?.[0]?.revised_prompt || \"\"\n};"
},
"id": "code-node-1",
"name": "Format Output",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
650,
300
]
}
],
"connections": {
"Webhook": {
"main": [
[
{
"node": "OpenAI - Generate Image",
"type": "main",
"index": 0
}
]
]
},
"OpenAI - Generate Image": {
"main": [
[
{
"node": "Format Output",
"type": "main",
"index": 0
}
]
]
}
},
"settings": {}
}

3249
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -9,9 +9,19 @@
"lint": "eslint"
},
"dependencies": {
"@modelcontextprotocol/sdk": "^1.29.0",
"@supabase/supabase-js": "^2.107.0",
"cheerio": "^1.2.0",
"date-fns": "^4.1.0",
"framer-motion": "^12.40.0",
"lucide-react": "^1.14.0",
"next": "16.2.6",
"openai": "^6.37.0",
"react": "19.2.4",
"react-dom": "19.2.4"
"react-dom": "19.2.4",
"react-markdown": "^10.1.0",
"recharts": "^3.8.1",
"remark-gfm": "^4.0.1"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",

View file

@ -0,0 +1,29 @@
import { NextRequest, NextResponse } from 'next/server';
import { analyzeProject } from '@/lib/ai/analyzer';
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const { link, description } = body;
if (!link && !description) {
return NextResponse.json(
{ success: false, error: 'Informe o link do projeto ou descreva seu produto/negócio para analisar.' },
{ status: 400 }
);
}
const analysis = await analyzeProject({ link, description });
return NextResponse.json({ success: true, data: analysis });
} catch (error) {
console.error('[AI] Error analyzing project link:', error);
return NextResponse.json(
{
success: false,
error: error instanceof Error ? error.message : 'Erro ao analisar o link.',
},
{ status: 500 }
);
}
}

View file

@ -0,0 +1,29 @@
import { NextRequest, NextResponse } from 'next/server';
import { generateAudienceSuggestion } from '@/lib/ai/copywriter';
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const { context, link, category } = body;
if (!context && !link) {
return NextResponse.json(
{ success: false, error: 'É necessário preencher os campos do formulário ou o Link.' },
{ status: 400 }
);
}
const suggestion = await generateAudienceSuggestion(context, link, category);
return NextResponse.json({ success: true, data: suggestion });
} catch (error) {
console.error('[AI] Error generating audience:', error);
return NextResponse.json(
{
success: false,
error: error instanceof Error ? error.message : 'AI error',
},
{ status: 500 }
);
}
}

View file

@ -0,0 +1,30 @@
import { NextRequest, NextResponse } from 'next/server';
import { generateAdCopy } from '@/lib/ai/copywriter';
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const { context, copy_rules, objective, tone, count, category } = body;
if (!context || !objective) {
return NextResponse.json(
{ success: false, error: 'context and objective are required' },
{ status: 400 }
);
}
const variations = await generateAdCopy({ context, copy_rules, objective, tone, count, category });
return NextResponse.json({ success: true, data: variations });
} catch (error) {
console.error('[AI] Error generating copy:', error);
return NextResponse.json(
{
success: false,
error: error instanceof Error ? error.message : 'AI error',
details: 'Verifique se a OPENAI_API_KEY no .env.local é uma chave válida da OpenAI (começa com sk-...)'
},
{ status: 500 }
);
}
}

View file

@ -0,0 +1,41 @@
import { NextRequest, NextResponse } from 'next/server';
import { generateAdImage } from '@/lib/ai/creative';
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const { context, style, dimensions, mood } = body;
if (!context || !style) {
return NextResponse.json(
{ success: false, error: 'context and style are required' },
{ status: 400 }
);
}
const dimensionsList: ('1080x1080' | '1080x1920' | '1200x628')[] = [
'1080x1080',
'1080x1920',
'1200x628'
];
const imagePromises = dimensionsList.map((dim) =>
generateAdImage({
context,
style,
dimensions: dim,
mood,
})
);
const images = await Promise.all(imagePromises);
return NextResponse.json({ success: true, data: images });
} catch (error) {
console.error('[AI] Error generating image:', error);
return NextResponse.json(
{ success: false, error: error instanceof Error ? error.message : 'AI error' },
{ status: 500 }
);
}
}

View file

@ -0,0 +1,53 @@
import { NextRequest, NextResponse } from 'next/server';
import { getAIClient } from '@/lib/ai/client';
import { getAccessToken } from '@/lib/auth/session';
const CHAT_MODEL = 'gpt-5.4-mini';
export async function POST(request: NextRequest) {
try {
const token = await getAccessToken();
if (!token) {
return NextResponse.json({ success: false, error: 'Not authenticated' }, { status: 401 });
}
const { messages, campaignName, reportContext } = await request.json();
if (!messages || !Array.isArray(messages)) {
return NextResponse.json({ success: false, error: 'Messages required' }, { status: 400 });
}
const openai = getAIClient();
const systemPrompt = `Você é um Consultor Sênior de Tráfego Pago conversando com o usuário sobre a campanha: "${campaignName || 'Campanha Desconhecida'}".
Sua missão é responder dúvidas técnicas do usuário sobre essa campanha, dar ideias de copy, explicar métricas e sugerir otimizações de forma amigável, direta e profissional. Use formatação Markdown (negrito, listas, etc) para deixar a resposta legível e bem estruturada.
[CONTEXTO/RELATÓRIO DA CAMPANHA]
${JSON.stringify(reportContext, null, 2)}`;
const chatMessages = [
{ role: 'system' as const, content: systemPrompt },
...messages.map((msg: any) => ({
role: msg.role === 'user' ? ('user' as const) : ('assistant' as const),
content: msg.content,
})),
];
const completion = await openai.chat.completions.create({
model: CHAT_MODEL,
messages: chatMessages,
});
return NextResponse.json({
success: true,
data: { reply: completion.choices[0]?.message?.content || '' }
});
} catch (error) {
console.error('[Chat API] Error:', error);
return NextResponse.json(
{ success: false, error: error instanceof Error ? error.message : 'Unknown error' },
{ status: 500 }
);
}
}

View file

@ -0,0 +1,144 @@
import { NextRequest, NextResponse } from 'next/server';
import { createMetaMCPClient } from '@/lib/mcp/client';
import { getCampaignDetails } from '@/lib/mcp/tools';
import { getAccessToken } from '@/lib/auth/session';
import { getAIClient } from '@/lib/ai/client';
const ANALYSIS_MODEL = 'gpt-5.4-mini';
export async function POST(request: NextRequest) {
try {
const token = await getAccessToken();
if (!token) {
return NextResponse.json({ success: false, error: 'Not authenticated' }, { status: 401 });
}
const { campaignId } = await request.json();
if (!campaignId) {
return NextResponse.json({ success: false, error: 'Campaign ID required' }, { status: 400 });
}
const client = await createMetaMCPClient(token);
let campaignData: any;
try {
const result = await getCampaignDetails(client, campaignId);
campaignData = Array.isArray(result) ? result[0] : result;
} finally {
await client.close();
}
if (!campaignData || !campaignData.id) {
return NextResponse.json({ success: false, error: 'Campaign not found on Meta' }, { status: 404 });
}
// Prepare data for the AI model
const insights = campaignData.insights?.data?.[0] || {};
const adsets = campaignData.adsets?.data || [];
let totalSpend = parseFloat(insights.spend || '0');
let roas = parseFloat(insights.purchase_roas?.[0]?.value || '0');
// Extract Ads & Creatives
const adsToAnalyze = [];
for (const adset of adsets) {
const ads = adset.ads?.data || [];
for (const ad of ads) {
const adInsights = ad.insights?.data?.[0] || {};
const creative = ad.creative || {};
// Extract texts
let body = creative.body || '';
let title = creative.title || '';
let imageUrl = creative.image_url || creative.thumbnail_url || '';
// Handle asset_feed_spec (Dynamic Creative)
if (creative.asset_feed_spec) {
const feed = creative.asset_feed_spec;
body = feed.bodies?.map((b: any) => b.text).join(' | ') || body;
title = feed.titles?.map((t: any) => t.text).join(' | ') || title;
if (feed.images && feed.images.length > 0) {
imageUrl = feed.images[0].url || feed.images[0].thumbnail_url || imageUrl; // Just take the first image for now
}
}
adsToAnalyze.push({
ad_name: ad.name,
spend: adInsights.spend || '0',
roas: adInsights.purchase_roas?.[0]?.value || '0',
cpc: adInsights.cpc || '0',
ctr: adInsights.ctr || '0',
body,
title,
image_url: imageUrl,
});
}
}
const openai = getAIClient();
const prompt = `
Você é um Especialista Sênior em Tráfego Pago e Copywriting.
Abaixo estão os dados reais extraídos de uma Campanha no Facebook Ads.
Por favor, faça uma auditoria completa.
DADOS DA CAMPANHA:
Nome: ${campaignData.name}
Gasto Total (30d): R$ ${totalSpend}
ROAS Total: ${roas}
ANÚNCIOS ATIVOS (CRIATIVOS E MÉTRICAS):
${JSON.stringify(adsToAnalyze, null, 2)}
Sua tarefa é retornar um JSON ESTRITAMENTE VÁLIDO com a seguinte estrutura de análise (NÃO adicione formatação markdown \`\`\`json no início ou no fim).
Importante: Para as análises e ações, retorne um ARRAY de strings (cada string é um bullet point claro, curto e direto).
{
"diagnostico_geral": "Um parágrafo forte resumindo a saúde da campanha.",
"analise_copy": ["Ponto 1 sobre a copy...", "Sugestão de melhoria de texto..."],
"analise_imagem": ["O que achei do visual...", "Ideia de novo formato (vídeo/carrossel)..."],
"analise_metricas": ["ROAS está X...", "CPA está Y..."],
"acao_recomendada": ["Ação imediata 1 (ex: Pausar Ad X)...", "Ação 2 (ex: Testar público Y)..."],
"nova_estrategia_sugerida": {
"nicho_sugerido": "ex: ecommerce, info_produto, negocios_locais, advogado...",
"produto_foco": "Nome ou tipo do produto que deve ser focado na próxima campanha",
"publico_alvo": "Qual deve ser o público alvo ideal",
"angulos_de_venda": ["Ângulo 1", "Ângulo 2"]
}
}
`;
const completion = await openai.chat.completions.create({
model: ANALYSIS_MODEL,
messages: [{ role: 'user', content: prompt }],
response_format: { type: 'json_object' },
});
const textRes = completion.choices[0]?.message?.content || '';
let jsonRes;
try {
// Clean up markdown fences if model ignored instruction
const cleanedText = textRes.replace(/^```json/m, '').replace(/```$/m, '').trim();
jsonRes = JSON.parse(cleanedText);
} catch (e) {
console.error('Failed to parse AI response:', textRes);
return NextResponse.json({ success: false, error: 'Failed to parse AI response' }, { status: 500 });
}
return NextResponse.json({
success: true,
data: {
campaign_metrics: { spend: totalSpend, roas },
ads_count: adsToAnalyze.length,
ads: adsToAnalyze,
analysis: jsonRes
}
});
} catch (error) {
console.error('[Analysis] API Error:', error);
return NextResponse.json(
{ success: false, error: error instanceof Error ? error.message : 'Unknown error' },
{ status: 500 }
);
}
}

View file

@ -0,0 +1,81 @@
// ============================================
// OAuth — Callback (exchange code for token)
// ============================================
import { NextRequest, NextResponse } from 'next/server';
import { cookies } from 'next/headers';
import { setSessionCookies } from '@/lib/auth/session';
import { getSettings } from '@/lib/settings';
export async function GET(request: NextRequest) {
const code = request.nextUrl.searchParams.get('code');
const error = request.nextUrl.searchParams.get('error');
if (error || !code) {
const errorDesc = request.nextUrl.searchParams.get('error_description') || 'Login cancelled';
return NextResponse.redirect(
new URL(`/login?error=${encodeURIComponent(errorDesc)}`, request.url)
);
}
const settings = getSettings();
const appId = settings.metaAppId || process.env.META_APP_ID;
const appSecret = settings.metaAppSecret || process.env.META_APP_SECRET;
const redirectUri = process.env.META_REDIRECT_URI || 'http://localhost:3000/api/auth/callback';
if (!appId || !appSecret) {
return NextResponse.redirect(
new URL(`/login?error=${encodeURIComponent('Credenciais Meta não configuradas no sistema.')}`, request.url)
);
}
try {
// 1. Exchange code for short-lived token
const tokenUrl = new URL('https://graph.facebook.com/v20.0/oauth/access_token');
tokenUrl.searchParams.set('client_id', appId);
tokenUrl.searchParams.set('client_secret', appSecret);
tokenUrl.searchParams.set('redirect_uri', redirectUri);
tokenUrl.searchParams.set('code', code);
const tokenRes = await fetch(tokenUrl.toString());
const tokenData = await tokenRes.json();
if (tokenData.error) {
throw new Error(tokenData.error.message);
}
const shortToken = tokenData.access_token;
// 2. Exchange for long-lived token (60 days)
const longTokenUrl = new URL('https://graph.facebook.com/v20.0/oauth/access_token');
longTokenUrl.searchParams.set('grant_type', 'fb_exchange_token');
longTokenUrl.searchParams.set('client_id', appId);
longTokenUrl.searchParams.set('client_secret', appSecret);
longTokenUrl.searchParams.set('fb_exchange_token', shortToken);
const longRes = await fetch(longTokenUrl.toString());
const longData = await longRes.json();
const accessToken = longData.access_token || shortToken;
// 3. Get user info
const meRes = await fetch(
`https://graph.facebook.com/v20.0/me?fields=name,picture.type(large)&access_token=${accessToken}`
);
const meData = await meRes.json();
// 4. Save session
const cookieStore = await cookies();
setSessionCookies(cookieStore, accessToken, {
name: meData.name || 'User',
picture: meData.picture?.data?.url || '',
});
// 5. Redirect to dashboard
return NextResponse.redirect(new URL('/dashboard', request.url));
} catch (err) {
console.error('[Auth] OAuth error:', err);
return NextResponse.redirect(
new URL(`/login?error=${encodeURIComponent('Erro na autenticação')}`, request.url)
);
}
}

View file

@ -0,0 +1,13 @@
// ============================================
// Auth — Logout
// ============================================
import { NextRequest, NextResponse } from 'next/server';
import { cookies } from 'next/headers';
import { clearSessionCookies } from '@/lib/auth/session';
export async function POST(request: NextRequest) {
const cookieStore = await cookies();
clearSessionCookies(cookieStore);
return NextResponse.redirect(new URL('/login', request.url));
}

View file

@ -0,0 +1,37 @@
// ============================================
// OAuth — Redirect to Meta Login
// ============================================
import { NextResponse } from 'next/server';
import { getSettings } from '@/lib/settings';
export async function GET() {
const settings = getSettings();
const appId = settings.metaAppId || process.env.META_APP_ID;
const redirectUri = process.env.META_REDIRECT_URI || 'http://localhost:3000/api/auth/callback';
if (!appId || appId === 'your_meta_app_id') {
return NextResponse.json(
{ error: 'App ID não configurado. Acesse a tela de login e clique em Configurar.' },
{ status: 500 }
);
}
const scopes = [
'ads_management',
'ads_read',
'business_management',
'pages_show_list',
'pages_read_engagement',
'instagram_basic',
].join(',');
const authUrl = new URL('https://www.facebook.com/v20.0/dialog/oauth');
authUrl.searchParams.set('client_id', appId);
authUrl.searchParams.set('redirect_uri', redirectUri);
authUrl.searchParams.set('scope', scopes);
authUrl.searchParams.set('response_type', 'code');
authUrl.searchParams.set('state', crypto.randomUUID());
return NextResponse.redirect(authUrl.toString());
}

View file

@ -0,0 +1,45 @@
import { NextRequest, NextResponse } from 'next/server';
import { createMetaMCPClient } from '@/lib/mcp/client';
import { updateCampaignBudget } from '@/lib/mcp/tools';
import { getAccessToken } from '@/lib/auth/session';
export async function PATCH(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const token = await getAccessToken();
if (!token) {
return NextResponse.json({ success: false, error: 'Not authenticated' }, { status: 401 });
}
const { id } = await params;
const body = await request.json();
const { daily_budget } = body;
if (!daily_budget || typeof daily_budget !== 'number' || daily_budget <= 0) {
return NextResponse.json(
{ success: false, error: 'Invalid budget. Must be a positive number in cents.' },
{ status: 400 }
);
}
const client = await createMetaMCPClient(token);
try {
await updateCampaignBudget(client, id, daily_budget);
return NextResponse.json({
success: true,
data: { campaignId: id, daily_budget },
});
} finally {
await client.close();
}
} catch (error) {
console.error('[API] Error updating campaign budget:', error);
return NextResponse.json(
{ success: false, error: error instanceof Error ? error.message : 'Failed to update budget' },
{ status: 500 }
);
}
}

View file

@ -0,0 +1,50 @@
import { NextRequest, NextResponse } from 'next/server';
import { createMetaMCPClient } from '@/lib/mcp/client';
import { pauseCampaign, resumeCampaign } from '@/lib/mcp/tools';
import { getAccessToken } from '@/lib/auth/session';
export async function PATCH(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const token = await getAccessToken();
if (!token) {
return NextResponse.json({ success: false, error: 'Not authenticated' }, { status: 401 });
}
const { id } = await params;
const body = await request.json();
const { status } = body;
if (!status || !['ACTIVE', 'PAUSED'].includes(status)) {
return NextResponse.json(
{ success: false, error: 'Invalid status. Must be ACTIVE or PAUSED.' },
{ status: 400 }
);
}
const client = await createMetaMCPClient(token);
try {
if (status === 'PAUSED') {
await pauseCampaign(client, id);
} else {
await resumeCampaign(client, id);
}
return NextResponse.json({
success: true,
data: { campaignId: id, status },
});
} finally {
await client.close();
}
} catch (error) {
console.error('[API] Error updating campaign status:', error);
return NextResponse.json(
{ success: false, error: error instanceof Error ? error.message : 'Failed to update status' },
{ status: 500 }
);
}
}

View file

@ -0,0 +1,270 @@
import { NextResponse } from 'next/server';
import { cookies } from 'next/headers';
import { createMetaMCPClient } from '@/lib/mcp/client';
import { createFullCampaign, getPages } from '@/lib/mcp/tools';
function toNumberOrUndefined(value: unknown): number | undefined {
if (value === undefined || value === null || value === '') return undefined;
const numberValue = Number(value);
if (!Number.isFinite(numberValue)) {
return undefined;
}
return numberValue;
}
function normalizeMoneyInCents(value: unknown, fallback: number): number {
const numberValue = toNumberOrUndefined(value);
if (numberValue === undefined) {
return fallback;
}
if (numberValue > 0 && numberValue < 100) {
throw new Error(
`Valor de orçamento/bid inválido: ${numberValue}. Envie em centavos. Exemplo: R$20,00 = 2000.`
);
}
return Math.round(numberValue);
}
function getFirstPageIdFromPages(pages: unknown): string | undefined {
if (!Array.isArray(pages)) return undefined;
const firstPage = pages.find((page: any) => page?.id);
return firstPage?.id;
}
function isWhatsappCampaign(body: Record<string, any>) {
const cta = String(body.cta || '').toUpperCase();
const destinationType = String(body.destination_type || '').toUpperCase();
return (
body.is_whatsapp === true ||
destinationType === 'WHATSAPP' ||
cta === 'WHATSAPP_MESSAGE' ||
Boolean(body.whatsapp_link) ||
Boolean(body.whatsapp_phone_number)
);
}
export async function POST(req: Request) {
let mcpClient: Awaited<ReturnType<typeof createMetaMCPClient>> | undefined;
try {
const cookieStore = await cookies();
const token = cookieStore.get('meta_access_token')?.value;
if (!token) {
return NextResponse.json(
{
success: false,
error: 'Unauthorized: meta_access_token cookie não encontrado.',
},
{ status: 401 }
);
}
const body = await req.json();
const {
campaign_name,
objective,
daily_budget,
bid_strategy,
bid_amount,
targeting,
page_id,
instagram_actor_id,
image_urls,
image_bytes,
link,
headline,
primary_text,
description,
cta,
ad_account_id,
optimization_goal,
billing_event,
destination_type,
promoted_object,
whatsapp_link,
whatsapp_phone_number,
} = body;
if (!campaign_name) {
return NextResponse.json(
{ success: false, error: 'campaign_name é obrigatório.' },
{ status: 400 }
);
}
if (!link && !whatsapp_link && !whatsapp_phone_number) {
return NextResponse.json(
{
success: false,
error: 'link, whatsapp_link ou whatsapp_phone_number é obrigatório.',
},
{ status: 400 }
);
}
if (!headline) {
return NextResponse.json(
{ success: false, error: 'headline é obrigatório.' },
{ status: 400 }
);
}
if (!primary_text) {
return NextResponse.json(
{ success: false, error: 'primary_text é obrigatório.' },
{ status: 400 }
);
}
const hasUrls = Array.isArray(image_urls) && image_urls.length > 0;
const hasBytes = Array.isArray(image_bytes) && image_bytes.length > 0;
if (!hasUrls && !hasBytes) {
return NextResponse.json(
{
success: false,
error: 'image_urls ou image_bytes (array) é obrigatório.',
},
{ status: 400 }
);
}
mcpClient = await createMetaMCPClient(token);
let finalPageId = page_id;
if (!finalPageId || finalPageId === '123456789') {
const pages = await getPages(mcpClient);
finalPageId = getFirstPageIdFromPages(pages);
}
if (!finalPageId) {
return NextResponse.json(
{
success: false,
error:
'Nenhuma página Meta encontrada. Envie page_id manualmente ou conecte uma página ao token.',
},
{ status: 400 }
);
}
const finalObjective = objective || 'OUTCOME_TRAFFIC';
const finalIsWhatsapp = isWhatsappCampaign(body);
const finalDailyBudget = normalizeMoneyInCents(daily_budget, 5000);
const finalBidAmount = toNumberOrUndefined(bid_amount);
if (bid_strategy === 'LOWEST_COST_WITH_BID_CAP' && !finalBidAmount) {
return NextResponse.json(
{
success: false,
error:
'bid_amount é obrigatório quando bid_strategy = LOWEST_COST_WITH_BID_CAP. Envie em centavos. Exemplo: R$10,00 = 1000.',
},
{ status: 400 }
);
}
if (finalBidAmount !== undefined && finalBidAmount > 0 && finalBidAmount < 100) {
return NextResponse.json(
{
success: false,
error:
'bid_amount deve ser enviado em centavos. Exemplo: R$10,00 = 1000, não 10.',
},
{ status: 400 }
);
}
if (bid_strategy === 'BID_CAP') {
if (!finalBidAmount || finalBidAmount <= 0) {
return NextResponse.json(
{ error: 'Você selecionou a estratégia de "Bid Cap", mas não informou o valor do lance (Bid). Por favor, volte e defina o valor do Bid Cap ou mude para "Volume Máximo".' },
{ status: 400 }
);
}
}
const { pixel_id } = body;
let finalOptimizationGoal = optimization_goal;
let finalPromotedObject = promoted_object;
if (finalObjective === 'OUTCOME_SALES' && pixel_id) {
finalOptimizationGoal = 'OFFSITE_CONVERSIONS';
finalPromotedObject = {
pixel_id: pixel_id,
custom_event_type: 'PURCHASE',
};
}
const result = await createFullCampaign(mcpClient, {
campaign_name,
objective: finalObjective,
daily_budget: finalDailyBudget,
bid_strategy: bid_strategy === 'BID_CAP' ? 'LOWEST_COST_WITH_BID_CAP' : 'LOWEST_COST_WITHOUT_CAP',
bid_amount: bid_amount,
targeting: {
...(targeting || {
geo_locations: {
countries: ['BR'],
},
}),
targeting_automation: {
advantage_audience: 0,
},
},
page_id: finalPageId,
instagram_actor_id: instagram_actor_id || undefined,
image_urls,
image_bytes,
link,
headline,
primary_text,
description: description || '',
cta: cta || (finalIsWhatsapp ? 'WHATSAPP_MESSAGE' : 'LEARN_MORE'),
ad_account_id,
optimization_goal: finalOptimizationGoal,
billing_event,
destination_type: destination_type || (finalIsWhatsapp ? 'WHATSAPP' : undefined),
promoted_object: finalPromotedObject,
is_whatsapp: finalIsWhatsapp,
whatsapp_link,
whatsapp_phone_number,
});
return NextResponse.json({
success: true,
data: result,
});
} catch (error: any) {
console.error('Create Campaign API Error:', error);
return NextResponse.json(
{
success: false,
error: error?.message || 'Internal Server Error',
details: {
message: error?.message,
stack: process.env.NODE_ENV === 'development' ? error?.stack : undefined,
},
},
{ status: 500 }
);
} finally {
if (mcpClient) {
await mcpClient.close();
}
}
}

View file

@ -0,0 +1,24 @@
import { NextRequest, NextResponse } from 'next/server';
import { getSupabaseServerClient } from '@/lib/supabase/server';
export async function GET(_request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
try {
const { id } = await params;
const supabase = getSupabaseServerClient();
const { data, error } = await supabase
.from('agent_campaigns')
.select('*')
.eq('id', id)
.single();
if (error) throw error;
return NextResponse.json({ success: true, data });
} catch (error) {
console.error('[Campaigns] Error fetching draft:', error);
return NextResponse.json(
{ success: false, error: error instanceof Error ? error.message : 'Erro ao buscar campanha salva.' },
{ status: 500 }
);
}
}

View file

@ -0,0 +1,93 @@
import { NextRequest, NextResponse } from 'next/server';
import { getSupabaseServerClient } from '@/lib/supabase/server';
const ALLOWED_FIELDS = [
'product',
'audience',
'objective',
'budget_daily',
'copy_variations',
'image_urls',
'targeting',
'config',
'status',
'meta_campaign_id',
'meta_adset_id',
'meta_creative_id',
'meta_ad_id',
'approved_at',
] as const;
function pickAllowedFields(body: Record<string, unknown>) {
const data: Record<string, unknown> = {};
for (const field of ALLOWED_FIELDS) {
if (body[field] !== undefined) data[field] = body[field];
}
return data;
}
// Saves or updates a campaign draft so generated copy/images survive errors
// during publish (Meta API failures, etc.) without needing to regenerate.
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const { id } = body;
const supabase = getSupabaseServerClient();
const data = pickAllowedFields(body);
if (id) {
const { data: row, error } = await supabase
.from('agent_campaigns')
.update(data as never)
.eq('id', id)
.select()
.single();
if (error) throw error;
return NextResponse.json({ success: true, data: row });
}
if (!data.product) {
return NextResponse.json(
{ success: false, error: 'product é obrigatório para salvar o rascunho.' },
{ status: 400 }
);
}
const { data: row, error } = await supabase
.from('agent_campaigns')
.insert({ ...data, status: data.status || 'draft' } as never)
.select()
.single();
if (error) throw error;
return NextResponse.json({ success: true, data: row });
} catch (error) {
console.error('[Campaigns] Error saving draft:', error);
return NextResponse.json(
{ success: false, error: error instanceof Error ? error.message : 'Erro ao salvar rascunho da campanha.' },
{ status: 500 }
);
}
}
export async function GET(request: NextRequest) {
try {
const supabase = getSupabaseServerClient();
const { data, error } = await supabase
.from('agent_campaigns')
.select('*')
.order('created_at', { ascending: false })
.limit(50);
if (error) throw error;
return NextResponse.json({ success: true, data });
} catch (error) {
console.error('[Campaigns] Error listing drafts:', error);
return NextResponse.json(
{ success: false, error: error instanceof Error ? error.message : 'Erro ao listar campanhas salvas.' },
{ status: 500 }
);
}
}

View file

@ -0,0 +1,53 @@
import { NextRequest, NextResponse } from 'next/server';
import { createMetaMCPClient } from '@/lib/mcp/client';
import { getAccountInsights } from '@/lib/mcp/tools';
import { getAccessToken } from '@/lib/auth/session';
export async function GET(request: NextRequest) {
try {
const token = await getAccessToken();
if (!token) {
return NextResponse.json({ success: false, error: 'Not authenticated' }, { status: 401 });
}
const searchParams = request.nextUrl.searchParams;
const datePreset = searchParams.get('date_preset') || 'last_30d';
const client = await createMetaMCPClient(token);
try {
const rawOverview: any = await getAccountInsights(client, datePreset);
// Calculate total leads from actions
const actions = rawOverview?.actions || [];
const totalLeads = actions.find((a: any) => a.action_type === 'lead')?.value || 0;
const purchaseValue = actions.find((a: any) => a.action_type === 'omni_purchase')?.value || 0;
const spend = parseFloat(rawOverview?.spend || '0');
const roas = spend > 0 && purchaseValue ? (parseFloat(purchaseValue) / spend) : 0;
// Map to expected AccountOverview structure
const overview = {
total_spend: spend,
total_leads: parseInt(totalLeads, 10),
total_clicks: parseInt(rawOverview?.clicks || '0', 10),
total_impressions: parseInt(rawOverview?.impressions || '0', 10),
total_roas: roas,
avg_ctr: parseFloat(rawOverview?.ctr || '0'),
avg_cpc: parseFloat(rawOverview?.cpc || '0'),
avg_cpm: parseFloat(rawOverview?.cpm || '0'),
campaigns_active: 0, // Not available directly in insights, could fetch if needed
};
return NextResponse.json({ success: true, data: overview });
} finally {
await client.close();
}
} catch (error) {
console.error('[API] Error fetching insights:', error);
return NextResponse.json(
{ success: false, error: error instanceof Error ? error.message : 'Failed to fetch insights' },
{ status: 500 }
);
}
}

View file

@ -0,0 +1,87 @@
import { NextRequest, NextResponse } from 'next/server';
import { createMetaMCPClient } from '@/lib/mcp/client';
import { listCampaigns } from '@/lib/mcp/tools';
import { getAccessToken } from '@/lib/auth/session';
function mapInsights(rawInsights: any) {
if (!rawInsights || !rawInsights.data || rawInsights.data.length === 0) return null;
const item = rawInsights.data[0];
const actions = item.actions || [];
const leads = actions.find((a: any) => a.action_type === 'lead' || a.action_type === 'onsite_conversion.lead_grouped')?.value || 0;
const purchase = actions.find((a: any) => a.action_type === 'omni_purchase')?.value || 0;
const spendNum = parseFloat(item.spend || '0');
const roas = spendNum > 0 && purchase ? parseFloat(purchase) / spendNum : 0;
return {
spend: item.spend || '0',
ctr: item.ctr || '0',
cpc: item.cpc || '0',
cpm: item.cpm || '0',
leads: parseInt(leads, 10),
roas,
};
}
export async function GET(request: NextRequest) {
try {
const token = await getAccessToken();
if (!token) {
return NextResponse.json({ success: false, error: 'Not authenticated' }, { status: 401 });
}
const searchParams = request.nextUrl.searchParams;
const datePreset = searchParams.get('date_preset') || 'last_30d';
const client = await createMetaMCPClient(token);
try {
// Obter campanhas, adsets e ads COM insights aninhados usando datePreset
const campaigns = await listCampaigns(client, undefined, datePreset);
const campaignsArray = Array.isArray(campaigns) ? campaigns : [];
const withInsights = campaignsArray.map((campaign: any) => {
// Map campaign insights
const campaignInsights = mapInsights(campaign.insights);
// Clean up raw insights from campaign object to keep it tidy, or just remap
const cleanedCampaign = { ...campaign };
delete cleanedCampaign.insights;
// Process AdSets
if (cleanedCampaign.adsets && cleanedCampaign.adsets.data) {
cleanedCampaign.adsets.data = cleanedCampaign.adsets.data.map((adset: any) => {
const adsetInsights = mapInsights(adset.insights);
const cleanedAdset = { ...adset };
if (adsetInsights) cleanedAdset.insights = adsetInsights;
// Process Ads inside AdSet
if (cleanedAdset.ads && cleanedAdset.ads.data) {
cleanedAdset.ads.data = cleanedAdset.ads.data.map((ad: any) => {
const adInsights = mapInsights(ad.insights);
const cleanedAd = { ...ad };
if (adInsights) cleanedAd.insights = adInsights;
return cleanedAd;
});
}
return cleanedAdset;
});
}
return { campaign: cleanedCampaign, insights: campaignInsights };
});
return NextResponse.json({ success: true, data: withInsights });
} finally {
await client.close();
}
} catch (error) {
console.error('[API] Error fetching campaigns:', error);
return NextResponse.json(
{ success: false, error: error instanceof Error ? error.message : 'Failed to fetch campaigns' },
{ status: 500 }
);
}
}

View file

@ -0,0 +1,70 @@
import { NextRequest, NextResponse } from 'next/server';
import { createMetaMCPClient } from '@/lib/mcp/client';
import { evaluateCampaigns } from '@/lib/engine/monitor';
import { DEFAULT_RULES } from '@/lib/engine/rules';
import type { AutomationRule } from '@/lib/engine/rules';
export async function POST(request: NextRequest) {
// Verify cron secret
const authHeader = request.headers.get('authorization');
const cronSecret = process.env.CRON_SECRET;
if (cronSecret && cronSecret !== 'your_random_cron_secret') {
if (authHeader !== `Bearer ${cronSecret}`) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
}
// For cron jobs, we need a stored token
// In production, store a long-lived System User token
const body = await request.json().catch(() => ({}));
const token = body.access_token || process.env.META_SYSTEM_TOKEN;
if (!token) {
return NextResponse.json(
{ success: false, error: 'No access token provided. Pass access_token in body or set META_SYSTEM_TOKEN env.' },
{ status: 401 }
);
}
try {
const rules: AutomationRule[] = DEFAULT_RULES.map((r, i) => ({
...r,
id: `default-${i}`,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
}));
const client = await createMetaMCPClient(token);
try {
const evaluations = await evaluateCampaigns(client, rules);
const triggered = evaluations.filter((e) => e.triggered);
console.log(`[Cron] Monitor run: ${triggered.length}/${evaluations.length} rules triggered`);
return NextResponse.json({
success: true,
data: {
ran_at: new Date().toISOString(),
total_evaluated: evaluations.length,
total_triggered: triggered.length,
actions: triggered.map((e) => ({
campaign: e.campaign_name,
rule: e.rule.name,
action: e.action_taken,
details: e.details,
})),
},
});
} finally {
await client.close();
}
} catch (error) {
console.error('[Cron] Monitor error:', error);
return NextResponse.json(
{ success: false, error: error instanceof Error ? error.message : 'Monitor error' },
{ status: 500 }
);
}
}

View file

@ -0,0 +1,15 @@
import { NextRequest, NextResponse } from 'next/server';
import { createMetaMCPClient } from '@/lib/mcp/client';
import { getAccessToken } from '@/lib/auth/session';
export async function GET(request: NextRequest) {
try {
const token = await getAccessToken();
if (!token) return NextResponse.json({ error: 'No token' }, { status: 401 });
const client = await createMetaMCPClient(token);
return NextResponse.json({ success: true });
} catch (error: any) {
return NextResponse.json({ error: error.message || String(error), stack: error.stack }, { status: 500 });
}
}

View file

@ -0,0 +1,67 @@
import { NextRequest, NextResponse } from 'next/server';
import { DEFAULT_RULES } from '@/lib/engine/rules';
import type { AutomationRule } from '@/lib/engine/rules';
// In-memory store (replace with Supabase when configured)
let rules: AutomationRule[] = DEFAULT_RULES.map((r, i) => ({
...r,
id: `rule-${i + 1}`,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
}));
export async function GET() {
return NextResponse.json({ success: true, data: rules });
}
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const newRule: AutomationRule = {
...body,
id: `rule-${Date.now()}`,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
};
rules.push(newRule);
return NextResponse.json({ success: true, data: newRule }, { status: 201 });
} catch (error) {
return NextResponse.json(
{ success: false, error: error instanceof Error ? error.message : 'Invalid data' },
{ status: 400 }
);
}
}
export async function PATCH(request: NextRequest) {
try {
const body = await request.json();
const { id, ...updates } = body;
const index = rules.findIndex((r) => r.id === id);
if (index === -1) {
return NextResponse.json({ success: false, error: 'Rule not found' }, { status: 404 });
}
rules[index] = { ...rules[index], ...updates, updated_at: new Date().toISOString() };
return NextResponse.json({ success: true, data: rules[index] });
} catch (error) {
return NextResponse.json(
{ success: false, error: error instanceof Error ? error.message : 'Invalid data' },
{ status: 400 }
);
}
}
export async function DELETE(request: NextRequest) {
try {
const { searchParams } = request.nextUrl;
const id = searchParams.get('id');
if (!id) return NextResponse.json({ success: false, error: 'ID required' }, { status: 400 });
rules = rules.filter((r) => r.id !== id);
return NextResponse.json({ success: true });
} catch (error) {
return NextResponse.json(
{ success: false, error: error instanceof Error ? error.message : 'Error' },
{ status: 500 }
);
}
}

View file

@ -0,0 +1,44 @@
import { NextRequest, NextResponse } from 'next/server';
import { createMetaMCPClient } from '@/lib/mcp/client';
import { evaluateCampaigns } from '@/lib/engine/monitor';
import { getAccessToken } from '@/lib/auth/session';
import type { AutomationRule, ActivityLog } from '@/lib/engine/rules';
import { DEFAULT_RULES } from '@/lib/engine/rules';
export async function POST(request: NextRequest) {
try {
const token = await getAccessToken();
if (!token) {
return NextResponse.json({ success: false, error: 'Not authenticated' }, { status: 401 });
}
const body = await request.json().catch(() => ({}));
const rules: AutomationRule[] = body.rules ||
DEFAULT_RULES.map((r, i) => ({ ...r, id: `default-${i}` }));
const recentLogs: ActivityLog[] = body.recent_logs || [];
const client = await createMetaMCPClient(token);
try {
const evaluations = await evaluateCampaigns(client, rules, recentLogs);
const triggered = evaluations.filter((e) => e.triggered);
return NextResponse.json({
success: true,
data: {
total_evaluated: evaluations.length,
total_triggered: triggered.length,
evaluations,
},
});
} finally {
await client.close();
}
} catch (error) {
console.error('[Engine] Error running evaluations:', error);
return NextResponse.json(
{ success: false, error: error instanceof Error ? error.message : 'Engine error' },
{ status: 500 }
);
}
}

View file

@ -0,0 +1,43 @@
import { NextResponse } from 'next/server';
import { createMetaMCPClient } from '@/lib/mcp/client';
import { getPages } from '@/lib/mcp/tools';
import { getAccessToken } from '@/lib/auth/session';
export async function GET() {
try {
const token = await getAccessToken();
if (!token) {
return NextResponse.json({ success: false, error: 'Not authenticated' }, { status: 401 });
}
const client = await createMetaMCPClient(token);
try {
const pages = await getPages(client);
const data = (Array.isArray(pages) ? pages : []).map((page: any) => ({
id: page.id,
name: page.name,
instagram_account: page.instagram_business_account
? {
id: page.instagram_business_account.id,
username: page.instagram_business_account.username,
profile_picture_url: page.instagram_business_account.profile_picture_url,
}
: null,
}));
return NextResponse.json({ success: true, data });
} finally {
await client.close();
}
} catch (error: any) {
console.error('[API] Error listing Meta pages:', error);
return NextResponse.json(
{ success: false, error: error?.message || 'Erro ao buscar páginas do Facebook.' },
{ status: 500 }
);
}
}

View file

@ -0,0 +1,24 @@
import { NextRequest, NextResponse } from 'next/server';
import { getSettings, saveSettings } from '@/lib/settings';
export async function GET() {
const settings = getSettings();
// Mask secrets so we don't send them back to the client
return NextResponse.json({
metaAppId: settings.metaAppId,
hasMetaSecret: !!settings.metaAppSecret,
hasOpenaiKey: !!settings.openaiApiKey,
});
}
export async function POST(request: NextRequest) {
try {
const body = await request.json();
saveSettings(body);
return NextResponse.json({ success: true });
} catch (error) {
console.error('[API] Error saving settings:', error);
return NextResponse.json({ success: false, error: 'Failed to save settings' }, { status: 500 });
}
}

View file

@ -0,0 +1,151 @@
'use client';
import React, { useState } from 'react';
import { Activity, CheckCircle, Clock, XCircle, Zap, Filter } from 'lucide-react';
interface LogEntry {
id: string;
timestamp: string;
campaign_name: string;
rule_name: string;
action: string;
details: string;
status: 'executed' | 'pending' | 'failed' | 'skipped';
}
// Demo data (will be replaced with Supabase query)
const DEMO_LOGS: LogEntry[] = [
{
id: '1', timestamp: new Date(Date.now() - 3600000).toISOString(),
campaign_name: 'Campanha Conversão Q1', rule_name: 'CPL Alto — Pausar',
action: 'pause', details: 'CPL = R$67.30 (threshold: R$50.00)', status: 'executed',
},
{
id: '2', timestamp: new Date(Date.now() - 7200000).toISOString(),
campaign_name: 'Remarketing Visitantes', rule_name: 'ROAS Baixo — Pausar',
action: 'pause', details: 'ROAS = 0.32x (threshold: 0.50x)', status: 'executed',
},
{
id: '3', timestamp: new Date(Date.now() - 14400000).toISOString(),
campaign_name: 'Tráfego Blog', rule_name: 'CTR Muito Baixo — Pausar',
action: 'pause', details: 'CTR = 0.21% (threshold: 0.50%)', status: 'executed',
},
{
id: '4', timestamp: new Date(Date.now() - 28800000).toISOString(),
campaign_name: 'Leads Premium', rule_name: 'ROAS Excelente — Aumentar Budget',
action: 'increase_budget', details: 'Budget +20%: R$50.00 → R$60.00. ROAS = 4.2x', status: 'executed',
},
{
id: '5', timestamp: new Date(Date.now() - 43200000).toISOString(),
campaign_name: 'Campanha Vídeo', rule_name: 'CPC Alto — Reduzir Budget',
action: 'reduce_budget', details: 'Em cooldown (48h)', status: 'skipped',
},
];
const statusConfig = {
executed: { icon: <CheckCircle size={14} />, color: '#4ade80', bg: 'rgba(34,197,94,0.1)', label: 'Executado' },
pending: { icon: <Clock size={14} />, color: '#fbbf24', bg: 'rgba(245,158,11,0.1)', label: 'Pendente' },
failed: { icon: <XCircle size={14} />, color: '#f87171', bg: 'rgba(239,68,68,0.1)', label: 'Falhou' },
skipped: { icon: <Clock size={14} />, color: '#8888a0', bg: 'var(--overlay-05)', label: 'Pulado' },
};
function timeAgo(dateStr: string): string {
const diff = Date.now() - new Date(dateStr).getTime();
const mins = Math.floor(diff / 60000);
if (mins < 60) return `${mins}min atrás`;
const hours = Math.floor(mins / 60);
if (hours < 24) return `${hours}h atrás`;
const days = Math.floor(hours / 24);
return `${days}d atrás`;
}
export default function ActivityPage() {
const [filter, setFilter] = useState<string>('all');
const logs = filter === 'all' ? DEMO_LOGS : DEMO_LOGS.filter((l) => l.status === filter);
return (
<div className="flex flex-col gap-6">
{/* Header */}
<div className="flex items-center justify-between animate-fade-in-up">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl flex items-center justify-center" style={{ background: 'rgba(34,197,94,0.15)', border: '1px solid rgba(34,197,94,0.25)' }}>
<Activity size={20} style={{ color: '#4ade80' }} />
</div>
<div>
<h1 className="text-2xl font-bold text-white">Atividade</h1>
<p className="text-sm" style={{ color: 'var(--muted-fg)' }}>Ações executadas pelo agente de automação</p>
</div>
</div>
<div className="flex items-center gap-1 p-1 rounded-xl" style={{ background: 'var(--overlay-03)', border: '1px solid var(--overlay-06)' }}>
<Filter size={14} className="ml-2" style={{ color: 'var(--muted-fg)' }} />
{['all', 'executed', 'pending', 'skipped', 'failed'].map((f) => (
<button key={f} onClick={() => setFilter(f)} className="px-3 py-1.5 rounded-lg text-xs font-medium transition-all"
style={{ background: filter === f ? 'var(--accent)' : 'transparent', color: filter === f ? 'white' : 'var(--muted-fg)' }}>
{f === 'all' ? 'Todos' : statusConfig[f as keyof typeof statusConfig].label}
</button>
))}
</div>
</div>
{/* Stats */}
<div className="grid grid-cols-4 gap-4 animate-fade-in-up delay-1">
{[
{ label: 'Total', value: DEMO_LOGS.length, color: 'var(--accent-soft-fg)' },
{ label: 'Executados', value: DEMO_LOGS.filter((l) => l.status === 'executed').length, color: '#4ade80' },
{ label: 'Pendentes', value: DEMO_LOGS.filter((l) => l.status === 'pending').length, color: '#fbbf24' },
{ label: 'Pulados', value: DEMO_LOGS.filter((l) => l.status === 'skipped').length, color: '#8888a0' },
].map((s) => (
<div key={s.label} className="glass-card-static p-4 text-center">
<p className="text-2xl font-bold" style={{ color: s.color }}>{s.value}</p>
<p className="text-xs mt-1" style={{ color: 'var(--muted-fg)' }}>{s.label}</p>
</div>
))}
</div>
{/* Timeline */}
<div className="glass-card-static overflow-hidden animate-fade-in-up delay-2">
<div className="p-5 border-b" style={{ borderColor: 'var(--overlay-06)' }}>
<h2 className="text-base font-semibold text-white">Timeline</h2>
</div>
<div className="divide-y" style={{ borderColor: 'var(--overlay-04)' }}>
{logs.length === 0 ? (
<div className="p-12 text-center">
<p className="text-[var(--muted-fg)]">Nenhuma atividade encontrada</p>
</div>
) : (
logs.map((log) => {
const sc = statusConfig[log.status];
return (
<div key={log.id} className="flex items-start gap-4 p-5 hover:bg-white/[0.02] transition-colors">
{/* Timeline dot */}
<div className="mt-1 flex flex-col items-center">
<div className="w-8 h-8 rounded-full flex items-center justify-center" style={{ background: sc.bg }}>
<Zap size={14} style={{ color: sc.color }} />
</div>
</div>
{/* Content */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<p className="font-medium text-white text-sm">{log.campaign_name}</p>
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[10px] font-medium" style={{ background: sc.bg, color: sc.color }}>
{sc.icon} {sc.label}
</span>
</div>
<p className="text-xs" style={{ color: 'var(--muted-fg)' }}>
Regra: <span className="text-white">{log.rule_name}</span>
</p>
<p className="text-xs mt-1" style={{ color: 'var(--muted-fg)' }}>{log.details}</p>
</div>
{/* Time */}
<span className="text-xs whitespace-nowrap" style={{ color: 'var(--muted-fg)' }}>
{timeAgo(log.timestamp)}
</span>
</div>
);
})
)}
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,462 @@
'use client';
import { useRouter } from 'next/navigation';
import React, { useState, useEffect } from 'react';
import { Spinner } from '@/components/ui/Spinner';
import { Zap, AlertTriangle, TrendingUp, TrendingDown, RefreshCcw, Bot, Activity, Send, User } from 'lucide-react';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
type Campaign = {
id: string;
name: string;
status: string;
};
export default function AnalysisPage() {
const router = useRouter();
const [campaigns, setCampaigns] = useState<Campaign[]>([]);
const [loadingList, setLoadingList] = useState(true);
const [selectedCampaign, setSelectedCampaign] = useState<string>('');
const [analyzing, setAnalyzing] = useState(false);
const [report, setReport] = useState<any>(null);
const [error, setError] = useState('');
// Chat State
const [chatMessages, setChatMessages] = useState<{role: string, content: string}[]>([]);
const [chatInput, setChatInput] = useState('');
const [isChatting, setIsChatting] = useState(false);
useEffect(() => {
fetch('/api/campaigns')
.then((res) => res.json())
.then((data) => {
if (data.success) {
setCampaigns(data.data || []);
}
})
.catch(() => {})
.finally(() => setLoadingList(false));
}, []);
const runAnalysis = async () => {
if (!selectedCampaign) return;
setAnalyzing(true);
setError('');
setReport(null);
setChatMessages([]);
try {
const res = await fetch('/api/analysis', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ campaignId: selectedCampaign }),
});
const data = await res.json();
if (data.success) {
setReport(data.data);
} else {
setError(data.error || 'Erro ao analisar campanha');
}
} catch (err) {
setError('Erro de conexão com o servidor');
} finally {
setAnalyzing(false);
}
};
const sendChatMessage = async (e?: React.FormEvent) => {
if (e) e.preventDefault();
if (!chatInput.trim() || isChatting || !report) return;
const newMsg = { role: 'user', content: chatInput };
const updatedMessages = [...chatMessages, newMsg];
setChatMessages(updatedMessages);
setChatInput('');
setIsChatting(true);
try {
const campName = campaigns.find(c => c.id === selectedCampaign)?.name || 'Campanha';
const res = await fetch('/api/analysis/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
campaignName: campName,
reportContext: report,
messages: updatedMessages
}),
});
const data = await res.json();
if (data.success) {
setChatMessages([...updatedMessages, { role: 'model', content: data.data.reply }]);
}
} catch (err) {
console.error(err);
} finally {
setIsChatting(false);
}
};
const handleCreateNewCampaign = () => {
if (!report?.analysis?.nova_estrategia_sugerida) return;
const strat = report.analysis.nova_estrategia_sugerida;
const q = new URLSearchParams({
niche: strat.nicho_sugerido || '',
product: strat.produto_foco || '',
audience: strat.publico_alvo || ''
});
router.push(`/dashboard/create?${q.toString()}`);
};
return (
<div className="flex flex-col gap-6 max-w-5xl mx-auto w-full pt-4">
{/* Header */}
<div className="flex items-center gap-3 animate-fade-in-up">
<div className="w-10 h-10 rounded-xl flex items-center justify-center" style={{ background: 'rgba(234,179,8,0.15)', border: '1px solid rgba(234,179,8,0.25)' }}>
<Bot size={20} style={{ color: '#eab308' }} />
</div>
<div>
<h1 className="text-2xl font-bold text-white">Auditoria com IA</h1>
<p className="text-sm" style={{ color: 'var(--muted-fg)' }}>A IA fará um Raio-X dos criativos e métricas da campanha</p>
</div>
</div>
<div className="glass-card-static p-6 animate-fade-in-up delay-1">
<label className="block text-xs uppercase tracking-wider font-medium mb-2 text-white/60">
Selecione uma Campanha
</label>
<div className="flex gap-4 items-center">
<div className="relative flex-1">
{loadingList ? (
<div className="input-field py-3 text-white/50 flex items-center gap-2">
<Spinner size="sm" /> Carregando campanhas...
</div>
) : (
<select
value={selectedCampaign}
onChange={(e) => setSelectedCampaign(e.target.value)}
className="input-field appearance-none w-full bg-black/40 border border-white/10 hover:border-white/20 focus:border-[var(--accent)] cursor-pointer pr-10 py-3"
>
<option value="" disabled className="bg-[#1a1a2e] text-white/50">-- Escolha uma Campanha --</option>
{campaigns.map((item: any) => {
const camp = item.campaign || item;
return (
<option key={camp.id} value={camp.id} className="bg-[#1a1a2e] text-white">
{camp.name} ({camp.status})
</option>
);
})}
</select>
)}
{!loadingList && (
<div className="pointer-events-none absolute inset-y-0 right-0 flex items-center px-4 text-white/50">
<svg className="fill-current h-4 w-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20">
<path d="M9.293 12.95l.707.707L15.657 8l-1.414-1.414L10 10.828 5.757 6.586 4.343 8z"/>
</svg>
</div>
)}
</div>
<button
onClick={runAnalysis}
disabled={!selectedCampaign || analyzing}
className="btn-primary py-3 px-6 h-[46px] flex items-center gap-2"
>
{analyzing ? <Spinner size="sm" /> : <Zap size={16} />}
{analyzing ? 'Analisando...' : 'Auditar'}
</button>
</div>
</div>
{error && (
<div className="px-5 py-3 rounded-xl animate-fade-in border border-red-500/20 bg-red-500/10">
<p className="text-sm text-red-400">{error}</p>
</div>
)}
{analyzing && (
<div className="glass-card p-12 text-center animate-fade-in-up delay-2 flex flex-col items-center justify-center">
<div className="w-16 h-16 rounded-full bg-[var(--accent)]/10 flex items-center justify-center mb-6 animate-pulse">
<Bot size={32} className="text-[var(--accent)]" />
</div>
<h3 className="text-lg font-bold text-white mb-2">A IA está lendo seus anúncios...</h3>
<p className="text-sm text-white/60 max-w-sm">
Baixando as imagens, lendo os textos persuasivos e cruzando com o ROAS e o CPA na Meta. Isso pode levar alguns segundos.
</p>
</div>
)}
{report && !analyzing && (
<div className="grid gap-6 animate-fade-in-up delay-2">
{/* Métricas Hero */}
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
<div className="glass-card-static p-4">
<p className="text-xs text-white/50 uppercase tracking-widest font-semibold mb-1">Anúncios Auditados</p>
<p className="text-2xl font-bold text-white">{report.ads_count}</p>
</div>
<div className="glass-card-static p-4">
<p className="text-xs text-white/50 uppercase tracking-widest font-semibold mb-1">Gasto (30d)</p>
<p className="text-2xl font-bold text-white">R$ {parseFloat(report.campaign_metrics.spend).toFixed(2)}</p>
</div>
<div className="glass-card-static p-4">
<p className="text-xs text-white/50 uppercase tracking-widest font-semibold mb-1">ROAS</p>
<p className="text-2xl font-bold text-[#4ade80]">{parseFloat(report.campaign_metrics.roas).toFixed(2)}x</p>
</div>
</div>
{/* Anúncios Ativos na Campanha */}
{report.ads && report.ads.length > 0 && (
<div className="glass-card-static p-6 border-t-4 border-t-blue-500">
<h3 className="text-sm font-semibold text-blue-400 uppercase tracking-wider mb-4 flex items-center gap-2">
<Zap size={16} /> Anúncios Auditados
</h3>
<div className="grid sm:grid-cols-2 md:grid-cols-3 gap-4">
{report.ads.map((ad: any, i: number) => (
<div key={i} className="bg-black/30 border border-white/10 rounded-xl overflow-hidden flex flex-col">
{ad.image_url ? (
<div className="aspect-square bg-black/50 relative">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={ad.image_url} alt={ad.ad_name} className="object-cover w-full h-full" />
</div>
) : (
<div className="aspect-square bg-white/5 flex flex-col items-center justify-center p-4 text-center">
<span className="text-xs text-white/40">Sem imagem disponível</span>
</div>
)}
<div className="p-4 flex-1 flex flex-col text-sm">
<p className="font-semibold text-white truncate mb-1" title={ad.ad_name}>{ad.ad_name}</p>
<div className="flex justify-between items-center text-xs text-white/50 mb-2">
<span>Gasto: R$ {parseFloat(ad.spend).toFixed(2)}</span>
<span className="text-[#4ade80]">ROAS: {parseFloat(ad.roas).toFixed(2)}x</span>
</div>
<p className="text-xs text-white/60 line-clamp-3 mt-auto">{ad.body || 'Sem texto'}</p>
</div>
</div>
))}
</div>
</div>
)}
{/* Relatório IA */}
<div className="glass-card-static p-6 grid gap-6 border-t-4 border-t-[var(--accent)]">
<div>
<h3 className="text-sm font-semibold text-[var(--accent)] uppercase tracking-wider mb-2 flex items-center gap-2">
<Activity size={16} /> Diagnóstico Geral
</h3>
<p className="text-white/80 leading-relaxed text-sm bg-black/20 p-4 rounded-xl border border-white/5">
{report.analysis.diagnostico_geral}
</p>
</div>
<div className="grid sm:grid-cols-2 gap-6">
<div>
<h3 className="text-sm font-semibold text-blue-400 uppercase tracking-wider mb-2 flex items-center gap-2">
<TrendingUp size={16} /> Análise de Texto (Copy)
</h3>
<ul className="text-white/80 leading-relaxed text-sm bg-black/20 p-4 rounded-xl border border-white/5 h-full space-y-2">
{Array.isArray(report.analysis.analise_copy) ? (
report.analysis.analise_copy.map((txt: string, i: number) => (
<li key={i} className="flex gap-2"><span className="text-blue-500 mt-1"></span> <span>{txt}</span></li>
))
) : (
<li>{report.analysis.analise_copy}</li>
)}
</ul>
</div>
<div>
<h3 className="text-sm font-semibold text-pink-400 uppercase tracking-wider mb-2 flex items-center gap-2">
<Zap size={16} /> Análise Visual (Imagem)
</h3>
<ul className="text-white/80 leading-relaxed text-sm bg-black/20 p-4 rounded-xl border border-white/5 h-full space-y-2">
{Array.isArray(report.analysis.analise_imagem) ? (
report.analysis.analise_imagem.map((txt: string, i: number) => (
<li key={i} className="flex gap-2"><span className="text-pink-500 mt-1"></span> <span>{txt}</span></li>
))
) : (
<li>{report.analysis.analise_imagem}</li>
)}
</ul>
</div>
</div>
<div className="grid sm:grid-cols-2 gap-6">
<div>
<h3 className="text-sm font-semibold text-yellow-400 uppercase tracking-wider mb-2 flex items-center gap-2">
<AlertTriangle size={16} /> Avaliação de Métricas
</h3>
<ul className="text-white/80 leading-relaxed text-sm bg-black/20 p-4 rounded-xl border border-white/5 h-full space-y-2">
{Array.isArray(report.analysis.analise_metricas) ? (
report.analysis.analise_metricas.map((txt: string, i: number) => (
<li key={i} className="flex gap-2"><span className="text-yellow-500 mt-1"></span> <span>{txt}</span></li>
))
) : (
<li>{report.analysis.analise_metricas}</li>
)}
</ul>
</div>
<div>
<h3 className="text-sm font-semibold text-green-400 uppercase tracking-wider mb-2 flex items-center gap-2">
<RefreshCcw size={16} /> Plano de Ação Recomendado
</h3>
<ul className="text-white/80 leading-relaxed text-sm bg-green-500/10 p-4 rounded-xl border border-green-500/20 h-full space-y-2 font-medium">
{Array.isArray(report.analysis.acao_recomendada) ? (
report.analysis.acao_recomendada.map((txt: string, i: number) => (
<li key={i} className="flex gap-2 items-start"><span className="text-green-400 mt-1"></span> <span>{txt}</span></li>
))
) : (
<li>{report.analysis.acao_recomendada}</li>
)}
</ul>
</div>
</div>
{/* Nova Estratégia Sugerida */}
{report.analysis.nova_estrategia_sugerida && (
<div className="mt-8 border-t border-white/10 pt-8 animate-fade-in-up">
<div className="flex items-center gap-3 mb-6">
<div className="w-10 h-10 rounded-xl bg-blue-500/10 border border-blue-500/20 flex items-center justify-center">
<TrendingUp size={20} className="text-blue-400" />
</div>
<div>
<h3 className="text-lg font-bold text-white">Estratégia Recomendada para a Próxima Campanha</h3>
<p className="text-xs text-white/50">Baseado no diagnóstico, a IA sugere os seguintes parâmetros para uma nova campanha</p>
</div>
</div>
<div className="bg-[#1a1a2e]/50 rounded-2xl border border-blue-500/20 p-6 flex flex-col md:flex-row gap-6 items-start">
<div className="flex-1 grid gap-4">
<div className="grid grid-cols-2 gap-4">
<div className="bg-black/30 p-4 rounded-xl border border-white/5">
<p className="text-xs text-white/40 uppercase font-semibold mb-1">Nicho / Setor</p>
<p className="text-sm text-white">{report.analysis.nova_estrategia_sugerida.nicho_sugerido}</p>
</div>
<div className="bg-black/30 p-4 rounded-xl border border-white/5">
<p className="text-xs text-white/40 uppercase font-semibold mb-1">Produto Foco</p>
<p className="text-sm text-white">{report.analysis.nova_estrategia_sugerida.produto_foco}</p>
</div>
</div>
<div className="bg-black/30 p-4 rounded-xl border border-white/5">
<p className="text-xs text-white/40 uppercase font-semibold mb-1">Público-Alvo Ideal</p>
<p className="text-sm text-white">{report.analysis.nova_estrategia_sugerida.publico_alvo}</p>
</div>
</div>
<div className="flex-1 flex flex-col gap-4 w-full">
<div className="bg-black/30 p-4 rounded-xl border border-white/5 h-full">
<p className="text-xs text-white/40 uppercase font-semibold mb-2">Ângulos de Venda (Copy)</p>
<ul className="text-sm text-white/80 space-y-1">
{report.analysis.nova_estrategia_sugerida.angulos_de_venda?.map((ang: string, i: number) => (
<li key={i} className="flex gap-2"><span className="text-blue-400"></span> {ang}</li>
))}
</ul>
</div>
<button
onClick={handleCreateNewCampaign}
className="w-full btn-primary py-3 px-6 h-[46px] flex items-center justify-center gap-2 shadow-lg shadow-blue-500/20"
>
<Zap size={16} />
Criar Nova Campanha com essa Estratégia
</button>
</div>
</div>
</div>
)}
{/* Chat com Especialista IA */}
<div className="mt-8 border-t border-white/10 pt-8">
<div className="flex items-center gap-3 mb-6">
<div className="w-10 h-10 rounded-xl bg-purple-500/10 border border-purple-500/20 flex items-center justify-center">
<Bot size={20} className="text-purple-400" />
</div>
<div>
<h3 className="text-lg font-bold text-white">Consultor IA</h3>
<p className="text-xs text-white/50">Fale com a IA sobre esta campanha e tire suas dúvidas</p>
</div>
</div>
<div className="bg-[#1a1a2e]/50 rounded-2xl border border-white/5 overflow-hidden flex flex-col h-[400px]">
<div className="flex-1 overflow-y-auto p-4 space-y-4">
{chatMessages.length === 0 ? (
<div className="flex flex-col items-center justify-center h-full text-center text-white/40">
<Bot size={32} className="mb-2 opacity-50" />
<p className="text-sm max-w-xs">Estou pronto para responder qualquer dúvida sobre essa análise. O que você gostaria de saber?</p>
</div>
) : (
chatMessages.map((msg, i) => (
<div key={i} className={`flex gap-3 ${msg.role === 'user' ? 'flex-row-reverse' : ''}`}>
<div className={`w-8 h-8 rounded-full flex items-center justify-center shrink-0 ${msg.role === 'user' ? 'bg-blue-500/20 text-blue-400' : 'bg-purple-500/20 text-purple-400'}`}>
{msg.role === 'user' ? <User size={14} /> : <Bot size={14} />}
</div>
<div className={`px-4 py-3 rounded-2xl max-w-[80%] text-sm ${
msg.role === 'user'
? 'bg-blue-500/10 border border-blue-500/20 text-white rounded-tr-none'
: 'bg-white/5 border border-white/10 text-white/80 rounded-tl-none'
}`}>
{msg.role === 'user' ? (
msg.content
) : (
<div className="prose prose-invert prose-sm max-w-none">
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{
p: ({node, ...props}) => <p className="mb-2 last:mb-0" {...props} />,
ul: ({node, ...props}) => <ul className="list-disc pl-4 mb-2" {...props} />,
ol: ({node, ...props}) => <ol className="list-decimal pl-4 mb-2" {...props} />,
li: ({node, ...props}) => <li className="mb-1" {...props} />,
strong: ({node, ...props}) => <strong className="font-semibold text-white" {...props} />
}}
>
{msg.content}
</ReactMarkdown>
</div>
)}
</div>
</div>
))
)}
{isChatting && (
<div className="flex gap-3">
<div className="w-8 h-8 rounded-full bg-purple-500/20 text-purple-400 flex items-center justify-center shrink-0">
<Bot size={14} />
</div>
<div className="px-4 py-3 rounded-2xl bg-white/5 border border-white/10 text-white/80 rounded-tl-none flex items-center gap-2">
<div className="w-1.5 h-1.5 rounded-full bg-white/40 animate-bounce"></div>
<div className="w-1.5 h-1.5 rounded-full bg-white/40 animate-bounce" style={{ animationDelay: '0.2s' }}></div>
<div className="w-1.5 h-1.5 rounded-full bg-white/40 animate-bounce" style={{ animationDelay: '0.4s' }}></div>
</div>
</div>
)}
</div>
<form onSubmit={sendChatMessage} className="p-3 bg-black/20 border-t border-white/5 flex gap-2">
<input
type="text"
value={chatInput}
onChange={(e) => setChatInput(e.target.value)}
placeholder="Pergunte algo sobre a campanha..."
className="flex-1 bg-transparent border-none outline-none text-white px-3 text-sm"
disabled={isChatting}
/>
<button
type="submit"
disabled={!chatInput.trim() || isChatting}
className="w-10 h-10 rounded-xl bg-[var(--accent)] hover:bg-[var(--accent)]/80 flex items-center justify-center text-black disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
<Send size={16} />
</button>
</form>
</div>
</div>
</div>
</div>
)}
</div>
);
}

View file

@ -0,0 +1,261 @@
'use client';
import React, { useState, useEffect } from 'react';
import { Spinner } from '@/components/ui/Spinner';
import { Modal } from '@/components/ui/Modal';
import {
Shield, Plus, Trash2, Zap, AlertTriangle,
TrendingDown, TrendingUp, Bell, Pause, Bot
} from 'lucide-react';
import type { AutomationRule, RuleMetric, RuleOperator, RuleAction } from '@/lib/engine/rules';
import { METRIC_LABELS, OPERATOR_LABELS, ACTION_LABELS } from '@/lib/engine/rules';
const actionIcons: Record<RuleAction, React.ReactNode> = {
pause: <Pause size={14} />,
reduce_budget: <TrendingDown size={14} />,
increase_budget: <TrendingUp size={14} />,
notify: <Bell size={14} />,
replace_with_ai: <Bot size={14} />,
};
const actionColors: Record<RuleAction, string> = {
pause: '#ef4444',
reduce_budget: '#f59e0b',
increase_budget: '#22c55e',
notify: '#3b82f6',
replace_with_ai: '#a855f7',
};
export default function AutomationPage() {
const [rules, setRules] = useState<AutomationRule[]>([]);
const [loading, setLoading] = useState(true);
const [showCreate, setShowCreate] = useState(false);
const [running, setRunning] = useState(false);
const [lastResult, setLastResult] = useState<string | null>(null);
// Form state
const [form, setForm] = useState({
name: '',
metric: 'cpl' as RuleMetric,
operator: '>' as RuleOperator,
threshold: 50,
min_spend: 30,
min_impressions: 1000,
action: 'pause' as RuleAction,
action_value: 30,
cooldown_hours: 24,
});
useEffect(() => {
fetchRules();
}, []);
const fetchRules = async () => {
setLoading(true);
try {
const res = await fetch('/api/engine/rules');
const data = await res.json();
if (data.success) setRules(data.data);
} catch { /* ignore */ }
setLoading(false);
};
const toggleRule = async (rule: AutomationRule) => {
await fetch('/api/engine/rules', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id: rule.id, enabled: !rule.enabled }),
});
setRules((prev) =>
prev.map((r) => (r.id === rule.id ? { ...r, enabled: !r.enabled } : r))
);
};
const deleteRule = async (id: string) => {
await fetch(`/api/engine/rules?id=${id}`, { method: 'DELETE' });
setRules((prev) => prev.filter((r) => r.id !== id));
};
const createRule = async () => {
const res = await fetch('/api/engine/rules', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ...form, enabled: true }),
});
const data = await res.json();
if (data.success) {
setRules((prev) => [...prev, data.data]);
setShowCreate(false);
setForm({ name: '', metric: 'cpl', operator: '>', threshold: 50, min_spend: 30, min_impressions: 1000, action: 'pause', action_value: 30, cooldown_hours: 24 });
}
};
const runEngine = async () => {
setRunning(true);
setLastResult(null);
try {
const res = await fetch('/api/engine/run', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ rules }),
});
const data = await res.json();
if (data.success) {
setLastResult(`✅ Avaliação concluída: ${data.data.total_triggered} de ${data.data.total_evaluated} regras disparadas`);
} else {
setLastResult(`❌ Erro: ${data.error}`);
}
} catch {
setLastResult('❌ Erro ao executar engine');
}
setRunning(false);
};
if (loading) return (
<div className="flex items-center justify-center py-20">
<Spinner size="lg" className="text-[var(--accent)]" />
</div>
);
return (
<div className="flex flex-col gap-6">
{/* Header */}
<div className="flex items-center justify-between animate-fade-in-up">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl flex items-center justify-center" style={{ background: 'rgba(99,102,241,0.15)', border: '1px solid rgba(99,102,241,0.25)' }}>
<Shield size={20} style={{ color: '#818cf8' }} />
</div>
<div>
<h1 className="text-2xl font-bold text-white">Automação</h1>
<p className="text-sm" style={{ color: 'var(--muted-fg)' }}>Regras automáticas de monitoramento</p>
</div>
</div>
<div className="flex items-center gap-3">
<button onClick={runEngine} disabled={running} className="btn-ghost flex items-center gap-2">
{running ? <Spinner size="sm" /> : <Zap size={14} />}
{running ? 'Executando...' : 'Executar agora'}
</button>
<button onClick={() => setShowCreate(true)} className="btn-primary flex items-center gap-2">
<Plus size={16} /> Nova Regra
</button>
</div>
</div>
{/* Last result banner */}
{lastResult && (
<div className="px-5 py-3 rounded-xl animate-fade-in" style={{ background: lastResult.startsWith('✅') ? 'rgba(34,197,94,0.08)' : 'rgba(239,68,68,0.08)', border: `1px solid ${lastResult.startsWith('✅') ? 'rgba(34,197,94,0.15)' : 'rgba(239,68,68,0.15)'}` }}>
<p className="text-sm" style={{ color: lastResult.startsWith('✅') ? '#4ade80' : '#f87171' }}>{lastResult}</p>
</div>
)}
{/* Rules list */}
<div className="grid gap-4">
{rules.length === 0 ? (
<div className="glass-card-static p-12 text-center">
<AlertTriangle size={40} className="mx-auto mb-4" style={{ color: 'var(--muted-fg)' }} />
<p className="text-[var(--muted-fg)]">Nenhuma regra configurada</p>
</div>
) : (
rules.map((rule) => (
<div key={rule.id} className={`glass-card p-5 animate-fade-in-up ${!rule.enabled ? 'opacity-50' : ''}`}>
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
{/* Toggle */}
<button
onClick={() => toggleRule(rule)}
className="relative w-11 h-6 rounded-full transition-colors duration-200"
style={{ background: rule.enabled ? 'var(--accent)' : 'var(--overlay-10)' }}
>
<span
className="absolute top-1 w-4 h-4 rounded-full bg-white transition-all duration-200"
style={{ left: rule.enabled ? '24px' : '4px' }}
/>
</button>
{/* Info */}
<div>
<p className="font-medium text-white text-sm">{rule.name}</p>
<p className="text-xs mt-0.5" style={{ color: 'var(--muted-fg)' }}>
Se <span className="text-white">{METRIC_LABELS[rule.metric]}</span>{' '}
{OPERATOR_LABELS[rule.operator]}{' '}
<span className="text-white font-medium">
{rule.metric === 'ctr' ? `${rule.threshold}%` : rule.metric === 'roas' ? `${rule.threshold}x` : `R$ ${rule.threshold}`}
</span>
{' → '}
<span style={{ color: actionColors[rule.action] }}>
{ACTION_LABELS[rule.action]}
{rule.action_value ? ` ${rule.action_value}%` : ''}
</span>
</p>
<p className="text-[10px] mt-1" style={{ color: 'var(--muted-fg)' }}>
Min: R${rule.min_spend} gasto, {rule.min_impressions} impressões Cooldown: {rule.cooldown_hours}h
</p>
</div>
</div>
<div className="flex items-center gap-3">
<span className="flex items-center gap-1.5 px-2.5 py-1 rounded-lg text-xs font-medium" style={{ background: `${actionColors[rule.action]}15`, color: actionColors[rule.action] }}>
{actionIcons[rule.action]} {ACTION_LABELS[rule.action]}
</span>
<button onClick={() => deleteRule(rule.id)} className="p-1.5 rounded-lg transition-all hover:bg-white/5" style={{ color: 'var(--muted-fg)' }}>
<Trash2 size={14} />
</button>
</div>
</div>
</div>
))
)}
</div>
{/* Create Modal */}
<Modal isOpen={showCreate} onClose={() => setShowCreate(false)} title="Nova Regra de Automação">
<div className="flex flex-col gap-4">
<div>
<label className="block text-xs uppercase tracking-wider font-medium mb-2" style={{ color: 'var(--muted-fg)' }}>Nome</label>
<input className="input-field" value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} placeholder="Ex: CPL Alto — Pausar" />
</div>
<div className="grid grid-cols-3 gap-3">
<div>
<label className="block text-xs uppercase tracking-wider font-medium mb-2" style={{ color: 'var(--muted-fg)' }}>Métrica</label>
<select className="input-field" value={form.metric} onChange={(e) => setForm({ ...form, metric: e.target.value as RuleMetric })}>
{Object.entries(METRIC_LABELS).map(([k, v]) => <option key={k} value={k}>{v}</option>)}
</select>
</div>
<div>
<label className="block text-xs uppercase tracking-wider font-medium mb-2" style={{ color: 'var(--muted-fg)' }}>Operador</label>
<select className="input-field" value={form.operator} onChange={(e) => setForm({ ...form, operator: e.target.value as RuleOperator })}>
{Object.entries(OPERATOR_LABELS).map(([k, v]) => <option key={k} value={k}>{v}</option>)}
</select>
</div>
<div>
<label className="block text-xs uppercase tracking-wider font-medium mb-2" style={{ color: 'var(--muted-fg)' }}>Threshold</label>
<input type="number" className="input-field" value={form.threshold} onChange={(e) => setForm({ ...form, threshold: parseFloat(e.target.value) })} />
</div>
</div>
<div>
<label className="block text-xs uppercase tracking-wider font-medium mb-2" style={{ color: 'var(--muted-fg)' }}>Ação</label>
<select className="input-field" value={form.action} onChange={(e) => setForm({ ...form, action: e.target.value as RuleAction })}>
{Object.entries(ACTION_LABELS).map(([k, v]) => <option key={k} value={k}>{v}</option>)}
</select>
</div>
<div className="grid grid-cols-3 gap-3">
<div>
<label className="block text-xs uppercase tracking-wider font-medium mb-2" style={{ color: 'var(--muted-fg)' }}>Min Spend (R$)</label>
<input type="number" className="input-field" value={form.min_spend} onChange={(e) => setForm({ ...form, min_spend: parseFloat(e.target.value) })} />
</div>
<div>
<label className="block text-xs uppercase tracking-wider font-medium mb-2" style={{ color: 'var(--muted-fg)' }}>Min Impressões</label>
<input type="number" className="input-field" value={form.min_impressions} onChange={(e) => setForm({ ...form, min_impressions: parseInt(e.target.value) })} />
</div>
<div>
<label className="block text-xs uppercase tracking-wider font-medium mb-2" style={{ color: 'var(--muted-fg)' }}>Cooldown (h)</label>
<input type="number" className="input-field" value={form.cooldown_hours} onChange={(e) => setForm({ ...form, cooldown_hours: parseInt(e.target.value) })} />
</div>
</div>
<div className="flex gap-3 mt-2">
<button onClick={() => setShowCreate(false)} className="btn-ghost flex-1">Cancelar</button>
<button onClick={createRule} disabled={!form.name} className="btn-primary flex-1">Criar Regra</button>
</div>
</div>
</Modal>
</div>
);
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,321 @@
'use client';
import React, { useEffect, useState } from 'react';
import { History, RefreshCw, CheckCircle, Clock, XCircle, ImageOff, ExternalLink } from 'lucide-react';
import { Spinner } from '@/components/ui/Spinner';
import { ErrorBlock } from '@/components/ui/ErrorBlock';
import { parseApiError, type FriendlyError } from '@/lib/errors';
interface CopyVariation {
headline: string;
primary_text: string;
description?: string;
cta?: string;
}
interface CampaignDraft {
id: string;
product: string;
audience: string | null;
objective: string | null;
budget_daily: number | null;
copy_variations: CopyVariation[];
image_urls: string[];
targeting: Record<string, unknown> | null;
config: {
campaign_name?: string;
link?: string;
pixel_id?: string;
bid_strategy?: string;
bid_amount?: number;
targeting_template?: string;
skin_id?: string;
} | null;
status: 'draft' | 'pending' | 'published' | 'active' | 'paused' | 'failed';
meta_campaign_id: string | null;
approved_at: string | null;
created_at: string;
}
const statusConfig: Record<string, { icon: React.ReactNode; color: string; bg: string; label: string }> = {
draft: { icon: <Clock size={12} />, color: 'var(--accent-soft-fg)', bg: 'rgba(165,180,252,0.12)', label: 'Rascunho' },
pending: { icon: <Clock size={12} />, color: '#fbbf24', bg: 'rgba(245,158,11,0.12)', label: 'Pendente' },
published: { icon: <CheckCircle size={12} />, color: '#4ade80', bg: 'rgba(34,197,94,0.12)', label: 'Publicada' },
active: { icon: <CheckCircle size={12} />, color: '#4ade80', bg: 'rgba(34,197,94,0.12)', label: 'Ativa' },
paused: { icon: <Clock size={12} />, color: '#8888a0', bg: 'var(--overlay-05)', label: 'Pausada' },
failed: { icon: <XCircle size={12} />, color: '#f87171', bg: 'rgba(239,68,68,0.12)', label: 'Falhou' },
};
function timeAgo(dateStr: string): string {
const diff = Date.now() - new Date(dateStr).getTime();
const mins = Math.floor(diff / 60000);
if (mins < 1) return 'agora mesmo';
if (mins < 60) return `${mins}min atrás`;
const hours = Math.floor(mins / 60);
if (hours < 24) return `${hours}h atrás`;
const days = Math.floor(hours / 24);
return `${days}d atrás`;
}
export default function HistoryPage() {
const [drafts, setDrafts] = useState<CampaignDraft[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [retryingId, setRetryingId] = useState<string | null>(null);
const [retryError, setRetryError] = useState<Record<string, string>>({});
const [retryErrorDetail, setRetryErrorDetail] = useState<Record<string, FriendlyError>>({});
const [manualLink, setManualLink] = useState<Record<string, string>>({});
const load = async () => {
setLoading(true);
setError('');
try {
const res = await fetch('/api/campaigns/draft');
const data = await res.json();
if (!data.success) throw new Error(data.error);
setDrafts(data.data || []);
} catch (err: any) {
setError(err.message || 'Erro ao carregar histórico de campanhas.');
} finally {
setLoading(false);
}
};
useEffect(() => {
load();
}, []);
const retryPublish = async (draft: CampaignDraft) => {
const copy = draft.copy_variations?.[0];
const config = draft.config || {};
const link = config.link || manualLink[draft.id]?.trim();
if (!copy) {
setRetryError((prev) => ({ ...prev, [draft.id]: 'Esta campanha não tem uma copy salva para reenviar.' }));
setRetryErrorDetail((prev) => { const next = { ...prev }; delete next[draft.id]; return next; });
return;
}
if (!link) {
setRetryError((prev) => ({ ...prev, [draft.id]: 'Informe o link de destino para reenviar esta campanha (esta campanha foi salva antes de guardarmos o link automaticamente).' }));
setRetryErrorDetail((prev) => { const next = { ...prev }; delete next[draft.id]; return next; });
return;
}
setRetryingId(draft.id);
setRetryError((prev) => ({ ...prev, [draft.id]: '' }));
setRetryErrorDetail((prev) => { const next = { ...prev }; delete next[draft.id]; return next; });
try {
const res = await fetch('/api/campaigns/create', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
campaign_name: config.campaign_name || draft.product,
objective: draft.objective,
daily_budget: Math.round((draft.budget_daily || 0) * 100),
bid_strategy: config.bid_strategy === 'BID_CAP' ? 'LOWEST_COST_WITH_BID_CAP' : undefined,
bid_amount: config.bid_strategy === 'BID_CAP' && config.bid_amount ? Math.round(config.bid_amount * 100) : undefined,
targeting: draft.targeting?.template ? undefined : draft.targeting,
link,
pixel_id: config.pixel_id,
image_urls: draft.image_urls,
headline: copy.headline,
primary_text: copy.primary_text,
description: copy.description,
cta: copy.cta || 'LEARN_MORE',
}),
});
const data = await res.json();
if (!data.success) {
console.error('[History] Retry publish error response:', data);
setRetryErrorDetail((prev) => ({ ...prev, [draft.id]: parseApiError(data, 'Não foi possível publicar a campanha na Meta.') }));
await fetch('/api/campaigns/draft', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id: draft.id, status: 'failed' }),
});
await load();
return;
}
await fetch('/api/campaigns/draft', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
id: draft.id,
status: 'published',
meta_campaign_id: data.data?.campaign_id,
meta_adset_id: data.data?.adset_id,
meta_creative_id: data.data?.creative_ids?.[0],
meta_ad_id: data.data?.ad_ids?.[0],
approved_at: new Date().toISOString(),
}),
});
await load();
} catch (err: any) {
setRetryError((prev) => ({ ...prev, [draft.id]: err.message || 'Erro ao tentar publicar novamente. Tente de novo em instantes.' }));
await fetch('/api/campaigns/draft', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id: draft.id, status: 'failed' }),
});
} finally {
setRetryingId(null);
}
};
return (
<div className="flex flex-col gap-6">
{/* Header */}
<div className="flex items-center justify-between animate-fade-in-up">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl flex items-center justify-center" style={{ background: 'rgba(165,180,252,0.15)', border: '1px solid rgba(165,180,252,0.25)' }}>
<History size={20} style={{ color: 'var(--accent-soft-fg)' }} />
</div>
<div>
<h1 className="text-2xl font-bold text-white">Histórico de Campanhas</h1>
<p className="text-sm" style={{ color: 'var(--muted-fg)' }}>Copys e criativos gerados salvos automaticamente, mesmo se a publicação falhar</p>
</div>
</div>
<button
onClick={load}
disabled={loading}
className="flex items-center gap-2 px-3 py-2 rounded-lg text-xs font-medium transition-all disabled:opacity-50"
style={{ background: 'var(--overlay-04)', border: '1px solid var(--overlay-08)', color: 'var(--muted-fg)' }}
>
{loading ? <Spinner size="sm" /> : <RefreshCw size={14} />}
Atualizar
</button>
</div>
{error && (
<div className="glass-card-static p-4 text-sm" style={{ color: '#f87171', borderColor: 'rgba(239,68,68,0.2)' }}>
{error}
</div>
)}
<div className="glass-card-static overflow-hidden animate-fade-in-up delay-1">
<div className="divide-y" style={{ borderColor: 'var(--overlay-04)' }}>
{!loading && drafts.length === 0 && (
<div className="p-12 text-center">
<p className="text-[var(--muted-fg)]">Nenhuma campanha salva ainda. Gere criativos em "Criar Campanha" para começar.</p>
</div>
)}
{drafts.map((draft) => {
const sc = statusConfig[draft.status] || statusConfig.draft;
const copy = draft.copy_variations?.[0];
const canRetry = draft.status === 'failed' || draft.status === 'draft';
return (
<div key={draft.id} className="p-5 hover:bg-white/[0.02] transition-colors">
<div className="flex items-start gap-4">
{/* Image thumbnail */}
<div className="w-20 h-20 rounded-lg overflow-hidden shrink-0 flex items-center justify-center" style={{ background: 'var(--overlay-04)' }}>
{draft.image_urls?.[0] ? (
<img src={draft.image_urls[0]} alt={draft.product} className="w-full h-full object-cover" />
) : (
<ImageOff size={20} style={{ color: 'var(--muted-fg)' }} />
)}
</div>
{/* Content */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1 flex-wrap">
<p className="font-medium text-white text-sm">{draft.config?.campaign_name || draft.product}</p>
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[10px] font-medium" style={{ background: sc.bg, color: sc.color }}>
{sc.icon} {sc.label}
</span>
{draft.objective && (
<span className="text-[10px] px-2 py-0.5 rounded-full" style={{ background: 'var(--overlay-04)', color: 'var(--muted-fg)' }}>
{draft.objective}
</span>
)}
{draft.budget_daily ? (
<span className="text-[10px] px-2 py-0.5 rounded-full" style={{ background: 'var(--overlay-04)', color: 'var(--muted-fg)' }}>
R$ {Number(draft.budget_daily).toFixed(2)}/dia
</span>
) : null}
</div>
{copy && (
<p className="text-xs text-white/80 mb-1">
<span className="font-medium">{copy.headline}</span> {copy.primary_text}
</p>
)}
{draft.audience && (
<p className="text-xs" style={{ color: 'var(--muted-fg)' }}>Público: {draft.audience}</p>
)}
{draft.meta_campaign_id && (
<p className="text-xs mt-1" style={{ color: '#4ade80' }}>
ID da campanha no Meta: {draft.meta_campaign_id}
</p>
)}
{retryErrorDetail[draft.id] ? (
<div className="mt-2">
<ErrorBlock error={retryErrorDetail[draft.id]} />
</div>
) : retryError[draft.id] ? (
<p className="text-xs mt-2 p-2 rounded-lg" style={{ background: 'rgba(239,68,68,0.08)', color: '#f87171' }}>
{retryError[draft.id]}
</p>
) : null}
{canRetry && !draft.config?.link && (
<div className="mt-2">
<input
type="text"
className="input-field text-xs py-1.5"
placeholder="https://seusite.com — link de destino (não foi salvo nesta campanha)"
value={manualLink[draft.id] || ''}
onChange={(e) => setManualLink((prev) => ({ ...prev, [draft.id]: e.target.value }))}
/>
</div>
)}
<div className="flex items-center gap-3 mt-2">
{canRetry && (
<button
onClick={() => retryPublish(draft)}
disabled={retryingId === draft.id}
className="flex items-center gap-1.5 text-xs font-medium transition-colors disabled:opacity-50"
style={{ color: 'var(--accent-soft-fg)' }}
>
{retryingId === draft.id ? <Spinner size="sm" /> : <RefreshCw size={12} />}
{retryingId === draft.id ? 'Publicando na Meta...' : 'Tentar publicar novamente'}
</button>
)}
{draft.status === 'published' && (
<a
href="https://adsmanager.facebook.com/adsmanager/manage/campaigns"
target="_blank"
rel="noreferrer"
className="flex items-center gap-1.5 text-xs font-medium transition-colors"
style={{ color: '#4ade80' }}
>
<ExternalLink size={12} />
Ver no Gerenciador de Anúncios
</a>
)}
</div>
</div>
{/* Time */}
<span className="text-xs whitespace-nowrap" style={{ color: 'var(--muted-fg)' }}>
{timeAgo(draft.created_at)}
</span>
</div>
</div>
);
})}
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,15 @@
import React from 'react';
import { Sidebar } from '@/components/dashboard/Sidebar';
export default function DashboardLayout({ children }: { children: React.ReactNode }) {
return (
<div className="flex min-h-screen" style={{ background: 'var(--background)' }}>
<Sidebar />
<main className="flex-1 min-w-0" style={{ marginLeft: '240px' }}>
<div className="p-8 max-w-[1400px] mx-auto">
{children}
</div>
</main>
</div>
);
}

220
src/app/dashboard/page.tsx Normal file
View file

@ -0,0 +1,220 @@
'use client';
import React, { useState, useEffect, useCallback } from 'react';
import { MetricsCard } from '@/components/dashboard/MetricsCard';
import { CampaignTable } from '@/components/dashboard/CampaignTable';
import { DateFilter } from '@/components/dashboard/DateFilter';
import { Spinner } from '@/components/ui/Spinner';
import { RefreshCw } from 'lucide-react';
import type { CampaignWithInsights, AccountOverview, DatePreset } from '@/lib/meta/types';
export default function DashboardPage() {
const [datePreset, setDatePreset] = useState<DatePreset>('last_30d');
const [campaigns, setCampaigns] = useState<CampaignWithInsights[]>([]);
const [overview, setOverview] = useState<AccountOverview | null>(null);
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [error, setError] = useState('');
const fetchData = useCallback(async (showLoading = true) => {
if (showLoading) setLoading(true);
else setRefreshing(true);
setError('');
try {
const [campaignsRes, overviewRes] = await Promise.all([
fetch(`/api/campaigns?date_preset=${datePreset}`),
fetch(`/api/campaigns/insights?date_preset=${datePreset}`),
]);
if (!campaignsRes.ok || !overviewRes.ok) {
throw new Error('Erro ao carregar dados');
}
const campaignsData = await campaignsRes.json();
const overviewData = await overviewRes.json();
if (campaignsData.success) setCampaigns(campaignsData.data);
if (overviewData.success) setOverview(overviewData.data);
} catch (err) {
setError(err instanceof Error ? err.message : 'Erro ao carregar dados');
} finally {
setLoading(false);
setRefreshing(false);
}
}, [datePreset]);
useEffect(() => {
fetchData();
}, [fetchData]);
// Auto-refresh every 60s
useEffect(() => {
const interval = setInterval(() => fetchData(false), 60000);
return () => clearInterval(interval);
}, [fetchData]);
const handleStatusChange = async (campaignId: string, newStatus: 'ACTIVE' | 'PAUSED') => {
const res = await fetch(`/api/campaigns/${campaignId}/status`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ status: newStatus }),
});
if (!res.ok) throw new Error('Erro ao atualizar status');
// Update local state
setCampaigns(prev =>
prev.map(c =>
c.campaign.id === campaignId
? { ...c, campaign: { ...c.campaign, status: newStatus } }
: c
)
);
};
const handleBudgetChange = async (campaignId: string, newBudget: number) => {
const res = await fetch(`/api/campaigns/${campaignId}/budget`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ daily_budget: newBudget }),
});
if (!res.ok) throw new Error('Erro ao atualizar orçamento');
// Update local state
setCampaigns(prev =>
prev.map(c =>
c.campaign.id === campaignId
? { ...c, campaign: { ...c.campaign, daily_budget: String(newBudget) } }
: c
)
);
};
const formatCurrency = (val: any) => `R$ ${Number(val || 0).toFixed(2)}`;
const formatNumber = (val: any) => Number(val || 0).toLocaleString('pt-BR');
return (
<div className="flex flex-col gap-6">
{/* Header */}
<div className="flex items-center justify-between animate-fade-in-up">
<div>
<h1 className="text-2xl font-bold text-white">Dashboard</h1>
<p className="text-sm mt-1" style={{ color: 'var(--muted-fg)' }}>
Visão geral das suas campanhas de Facebook Ads
</p>
</div>
<div className="flex items-center gap-4">
<DateFilter selected={datePreset} onChange={setDatePreset} />
<button
onClick={() => fetchData(false)}
disabled={refreshing}
className="btn-ghost flex items-center gap-2"
>
<RefreshCw size={14} className={refreshing ? 'animate-spin-slow' : ''} />
Atualizar
</button>
</div>
</div>
{/* Error */}
{error && (
<div className="px-5 py-4 rounded-xl animate-fade-in" style={{ background: 'rgba(239,68,68,0.08)', border: '1px solid rgba(239,68,68,0.15)' }}>
<p className="text-sm" style={{ color: '#f87171' }}>{error}</p>
<p className="text-xs mt-1" style={{ color: 'var(--muted-fg)' }}>
Verifique se as credenciais do Meta estão configuradas no .env.local
</p>
</div>
)}
{/* Metrics Cards */}
{loading ? (
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-4">
{Array.from({ length: 6 }).map((_, i) => (
<div key={i} className="shimmer h-[120px]" />
))}
</div>
) : overview ? (
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-4">
<MetricsCard
title="Investido"
value={formatCurrency(overview.total_spend)}
subtitle={`${overview.campaigns_active} campanhas ativas`}
icon="dollar"
color="purple"
delay={1}
/>
<MetricsCard
title="Leads"
value={formatNumber(overview.total_leads)}
subtitle={overview.total_leads > 0 ? `CPL: ${formatCurrency(overview.total_spend / overview.total_leads)}` : undefined}
icon="users"
color="green"
delay={2}
/>
<MetricsCard
title="ROAS"
value={overview.total_roas > 0 ? `${overview.total_roas.toFixed(2)}x` : '—'}
subtitle="Retorno sobre investimento"
icon="trending"
color="blue"
delay={3}
/>
<MetricsCard
title="CTR"
value={`${overview.avg_ctr.toFixed(2)}%`}
subtitle={`${formatNumber(overview.total_clicks)} cliques`}
icon="target"
color="amber"
delay={4}
/>
<MetricsCard
title="CPC"
value={formatCurrency(overview.avg_cpc)}
subtitle="Custo por clique"
icon="click"
color="pink"
delay={5}
/>
<MetricsCard
title="CPM"
value={formatCurrency(overview.avg_cpm)}
subtitle={`${formatNumber(overview.total_impressions)} impressões`}
icon="chart"
color="cyan"
delay={6}
/>
</div>
) : null}
{/* Campaigns Table */}
<CampaignTable
campaigns={campaigns}
loading={loading}
onStatusChange={handleStatusChange}
onBudgetChange={handleBudgetChange}
/>
{/* Footer */}
{!loading && (
<div className="flex items-center justify-between animate-fade-in">
<p className="text-xs" style={{ color: 'var(--muted-fg)' }}>
{campaigns.length} campanha{campaigns.length !== 1 ? 's' : ''} encontrada{campaigns.length !== 1 ? 's' : ''}
</p>
<p className="text-xs" style={{ color: 'var(--muted-fg)' }}>
Auto-refresh: 60s Dados via Meta Marketing API v20.0
</p>
</div>
)}
{/* Loading overlay on refresh */}
{refreshing && (
<div className="fixed bottom-6 right-6 flex items-center gap-2 px-4 py-2.5 rounded-xl animate-fade-in" style={{ background: 'rgba(99,102,241,0.15)', border: '1px solid rgba(99,102,241,0.25)' }}>
<Spinner size="sm" className="text-[var(--accent)]" />
<span className="text-xs font-medium" style={{ color: 'var(--accent-soft-fg)' }}>Atualizando...</span>
</div>
)}
</div>
);
}

View file

@ -0,0 +1,197 @@
'use client';
import React, { useEffect, useState } from 'react';
import { Save, Key, AppWindow, ShieldAlert } from 'lucide-react';
import { Spinner } from '@/components/ui/Spinner';
export default function SettingsPage() {
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [message, setMessage] = useState<{ text: string; type: 'success' | 'error' } | null>(null);
const [metaAppId, setMetaAppId] = useState('');
const [metaAppSecret, setMetaAppSecret] = useState('');
const [openaiKey, setOpenaiKey] = useState('');
const [hasMetaSecret, setHasMetaSecret] = useState(false);
const [hasOpenaiKey, setHasOpenaiKey] = useState(false);
useEffect(() => {
fetch('/api/settings')
.then(res => res.json())
.then(data => {
setMetaAppId(data.metaAppId || '');
setHasMetaSecret(data.hasMetaSecret);
setHasOpenaiKey(data.hasOpenaiKey);
setLoading(false);
})
.catch(err => {
console.error(err);
setLoading(false);
});
}, []);
const handleSave = async (e: React.FormEvent) => {
e.preventDefault();
setSaving(true);
setMessage(null);
const payload: Record<string, string> = { metaAppId };
// Only send secrets if they were changed
if (metaAppSecret) payload.metaAppSecret = metaAppSecret;
if (openaiKey) payload.openaiApiKey = openaiKey;
try {
const res = await fetch('/api/settings', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
if (!res.ok) throw new Error('Erro ao salvar');
setMessage({ text: 'Configurações salvas com sucesso!', type: 'success' });
// Update UI state
if (metaAppSecret) setHasMetaSecret(true);
if (openaiKey) setHasOpenaiKey(true);
setMetaAppSecret('');
setOpenaiKey('');
} catch (error) {
setMessage({ text: 'Erro ao salvar as configurações', type: 'error' });
} finally {
setSaving(false);
}
};
if (loading) {
return (
<div className="flex h-[50vh] items-center justify-center">
<Spinner />
</div>
);
}
return (
<div className="max-w-3xl mx-auto pb-10 animate-fade-in">
<div className="mb-8">
<h1 className="text-2xl font-bold text-white mb-2">Configurações do Sistema</h1>
<p className="text-sm" style={{ color: 'var(--muted-fg)' }}>
Configure as chaves de API necessárias para o funcionamento do app. Não é necessário editar o arquivo .env.
</p>
</div>
{message && (
<div className={`p-4 rounded-xl mb-6 flex items-center gap-3 ${
message.type === 'success'
? 'bg-green-500/10 border border-green-500/20 text-green-400'
: 'bg-red-500/10 border border-red-500/20 text-red-400'
}`}>
{message.text}
</div>
)}
<form onSubmit={handleSave} className="space-y-6">
{/* Meta Section */}
<div className="p-6 rounded-2xl" style={{ background: 'var(--card-bg)', border: '1px solid var(--card-border)' }}>
<div className="flex items-center gap-3 mb-6 pb-4 border-b" style={{ borderColor: 'var(--card-border)' }}>
<div className="p-2 bg-blue-500/10 rounded-lg text-blue-400">
<AppWindow size={20} />
</div>
<div>
<h2 className="font-semibold text-white">Meta for Developers</h2>
<p className="text-xs" style={{ color: 'var(--muted-fg)' }}>Credenciais para o login OAuth e MCP Oficial</p>
</div>
</div>
<div className="space-y-4">
<div>
<label className="block text-xs uppercase tracking-wider font-medium mb-2" style={{ color: 'var(--muted-fg)' }}>
Meta App ID
</label>
<input
type="text"
value={metaAppId}
onChange={e => setMetaAppId(e.target.value)}
className="input-field"
placeholder="Ex: 123456789012345"
/>
</div>
<div>
<label className="block text-xs uppercase tracking-wider font-medium mb-2" style={{ color: 'var(--muted-fg)' }}>
Meta App Secret
</label>
<input
type="password"
value={metaAppSecret}
onChange={e => setMetaAppSecret(e.target.value)}
className="input-field"
placeholder={hasMetaSecret ? "•••••••••••••••• (Preenchido)" : "Ex: abc123def456..."}
/>
<p className="text-[11px] mt-1.5" style={{ color: 'var(--muted-fg)' }}>
{hasMetaSecret ? 'Deixe em branco para manter a chave atual.' : 'Obrigatório para realizar o login do usuário.'}
</p>
</div>
<div className="mt-4 p-3 rounded-lg flex items-start gap-3" style={{ background: 'rgba(59, 130, 246, 0.1)' }}>
<ShieldAlert size={16} className="text-blue-400 mt-0.5 shrink-0" />
<div className="text-[11px] text-blue-200">
<p className="font-semibold mb-1">Atenção com a URL de Redirecionamento</p>
<p>No painel do Meta Developers, configure a <strong>Valid OAuth Redirect URI</strong> como:</p>
<code className="block mt-1 px-2 py-1 bg-black/30 rounded text-blue-300 select-all">
{typeof window !== 'undefined' ? `${window.location.origin}/api/auth/callback` : '...'}
</code>
</div>
</div>
</div>
</div>
{/* OpenAI Section */}
<div className="p-6 rounded-2xl" style={{ background: 'var(--card-bg)', border: '1px solid var(--card-border)' }}>
<div className="flex items-center gap-3 mb-6 pb-4 border-b" style={{ borderColor: 'var(--card-border)' }}>
<div className="p-2 bg-purple-500/10 rounded-lg text-purple-400">
<Key size={20} />
</div>
<div>
<h2 className="font-semibold text-white">OpenAI</h2>
<p className="text-xs" style={{ color: 'var(--muted-fg)' }}>Para geração de copy e criativos com IA</p>
</div>
</div>
<div>
<label className="block text-xs uppercase tracking-wider font-medium mb-2" style={{ color: 'var(--muted-fg)' }}>
API Key (GPT-4o + DALL-E)
</label>
<input
type="password"
value={openaiKey}
onChange={e => setOpenaiKey(e.target.value)}
className="input-field"
placeholder={hasOpenaiKey ? "•••••••••••••••• (Preenchido)" : "sk-..."}
/>
<p className="text-[11px] mt-1.5" style={{ color: 'var(--muted-fg)' }}>
{hasOpenaiKey ? 'Deixe em branco para manter a chave atual.' : 'Obrigatório para gerar textos e imagens.'}
</p>
</div>
</div>
{/* Actions */}
<div className="flex justify-end pt-4">
<button
type="submit"
disabled={saving}
className="btn-primary flex items-center gap-2 px-6"
>
{saving ? <Spinner size="sm" /> : <Save size={18} />}
{saving ? 'Salvando...' : 'Salvar Configurações'}
</button>
</div>
</form>
</div>
);
}

View file

@ -0,0 +1,65 @@
import { LegalPage, LegalSection } from '@/components/public/LegalPage';
export const metadata = {
title: 'Exclusão de Dados — MetaAds Pro',
};
export default function DataDeletionPage() {
return (
<LegalPage title="Instruções para Exclusão de Dados" updatedAt="07 de junho de 2026">
<p>
Você tem total controle sobre os dados que o <strong style={{ color: 'var(--foreground)' }}>MetaAds Pro</strong> armazena
a partir da sua conexão com o Facebook/Meta. Esta página explica como solicitar a remoção desses dados.
</p>
<LegalSection title="1. Revogar o acesso do aplicativo (Facebook)">
<p>
A forma mais rápida de interromper o acesso do MetaAds Pro à sua conta é remover a conexão diretamente
nas configurações do Facebook:
</p>
<ol className="list-decimal pl-5 flex flex-col gap-1">
<li>Acesse <strong style={{ color: 'var(--foreground)' }}>Configurações Aplicativos e sites</strong> na sua conta do Facebook;</li>
<li>Localize <strong style={{ color: 'var(--foreground)' }}>MetaAds Pro</strong> na lista de aplicativos conectados;</li>
<li>Selecione "Remover" para revogar o acesso e excluir a conexão.</li>
</ol>
<p>
Ao remover o aplicativo, a Meta também nos notifica para que possamos apagar o token de acesso e os
dados associados à sua conta.
</p>
</LegalSection>
<LegalSection title="2. Solicitar exclusão completa do histórico salvo">
<p>
Além de revogar o acesso, você pode solicitar que apaguemos permanentemente todo o histórico de
campanhas, copys, criativos e configurações salvos na nossa base de dados. Para isso, envie um e-mail
para <a href="mailto:bevervansomarcio@gmail.com?subject=Solicita%C3%A7%C3%A3o%20de%20exclus%C3%A3o%20de%20dados" className="underline" style={{ color: 'var(--accent)' }}>bevervansomarcio@gmail.com</a> a
partir do mesmo endereço/conta usada no aplicativo, com o assunto <em>"Solicitação de exclusão de dados"</em>.
</p>
<p>
Processamos solicitações de exclusão em até <strong style={{ color: 'var(--foreground)' }}>30 dias</strong>, e
você receberá uma confirmação por e-mail assim que a remoção for concluída.
</p>
</LegalSection>
<LegalSection title="3. O que é excluído">
<ul className="list-disc pl-5 flex flex-col gap-1">
<li>Token de acesso OAuth e dados de sessão associados à sua conta;</li>
<li>Histórico de campanhas, copys, imagens geradas e configurações salvas na plataforma;</li>
<li>Quaisquer registros de atividade ou diagnósticos de IA gerados durante o uso.</li>
</ul>
<p>
Dados que existem na sua conta de anúncios do Meta (campanhas publicadas, métricas, gastos) são
gerenciados diretamente pela Meta e não são afetados pela exclusão dos dados armazenados pelo MetaAds Pro
para removê-los, utilize as ferramentas do próprio Gerenciador de Anúncios.
</p>
</LegalSection>
<LegalSection title="4. Mais informações">
<p>
Para detalhes sobre quais dados coletamos e como os utilizamos, consulte nossa{' '}
<a href="/privacy" className="underline" style={{ color: 'var(--accent)' }}>Política de Privacidade</a>.
</p>
</LegalSection>
</LegalPage>
);
}

View file

@ -1,26 +1,512 @@
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800;900&display=swap');
@import "tailwindcss";
:root {
--background: #ffffff;
--foreground: #171717;
}
/* ============================================
MetaAds Pro Design System (Tailwind v4)
============================================ */
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
--color-card: var(--card);
--color-card-border: var(--card-border);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-fg);
--color-accent: var(--accent);
--color-accent-hover: var(--accent-hover);
--color-success: var(--success);
--color-warning: var(--warning);
--color-danger: var(--danger);
--color-info: var(--info);
--color-sidebar-bg: var(--sidebar-bg);
--color-sidebar-hover: var(--sidebar-hover);
--color-input-bg: var(--input-bg);
--color-input-border: var(--input-border);
--font-sans: 'Inter', system-ui, -apple-system, sans-serif;
}
@media (prefers-color-scheme: dark) {
:root {
--background: #0a0a0a;
--foreground: #ededed;
}
:root,
[data-theme='dark'] {
--background: #06060b;
--foreground: #f0f0f5;
--card: rgba(255, 255, 255, 0.04);
--card-border: rgba(255, 255, 255, 0.08);
--muted: #1a1a2e;
--muted-fg: #8888a0;
--accent: #6366f1;
--accent-hover: #818cf8;
--success: #22c55e;
--warning: #f59e0b;
--danger: #ef4444;
--info: #3b82f6;
--sidebar-bg: #0c0c14;
--sidebar-hover: rgba(99, 102, 241, 0.1);
--input-bg: rgba(255, 255, 255, 0.05);
--input-border: rgba(255, 255, 255, 0.1);
--modal-bg: #12121e;
--glass-bg: rgba(255, 255, 255, 0.03);
--glass-border: rgba(255, 255, 255, 0.06);
--grid-line: rgba(255, 255, 255, 0.02);
--hairline: rgba(255, 255, 255, 0.06);
--overlay-01: rgba(255, 255, 255, 0.01);
--overlay-02: rgba(255, 255, 255, 0.02);
--overlay-03: rgba(255, 255, 255, 0.03);
--overlay-04: rgba(255, 255, 255, 0.04);
--overlay-05: rgba(255, 255, 255, 0.05);
--overlay-06: rgba(255, 255, 255, 0.06);
--overlay-08: rgba(255, 255, 255, 0.08);
--overlay-10: rgba(255, 255, 255, 0.1);
--accent-soft-fg: #a5b4fc;
color-scheme: dark;
}
[data-theme='light'] {
--background: #f5f5fa;
--foreground: #14141f;
--card: rgba(20, 20, 31, 0.03);
--card-border: rgba(20, 20, 31, 0.08);
--muted: #e8e8f2;
--muted-fg: #6b6b80;
--accent: #6366f1;
--accent-hover: #4f52d6;
--success: #16a34a;
--warning: #d97706;
--danger: #dc2626;
--info: #2563eb;
--sidebar-bg: #ffffff;
--sidebar-hover: rgba(99, 102, 241, 0.08);
--input-bg: rgba(20, 20, 31, 0.03);
--input-border: rgba(20, 20, 31, 0.12);
--modal-bg: #ffffff;
--glass-bg: rgba(255, 255, 255, 0.7);
--glass-border: rgba(20, 20, 31, 0.08);
--grid-line: rgba(20, 20, 31, 0.04);
--hairline: rgba(20, 20, 31, 0.08);
--overlay-01: rgba(20, 20, 31, 0.02);
--overlay-02: rgba(20, 20, 31, 0.03);
--overlay-03: rgba(20, 20, 31, 0.04);
--overlay-04: rgba(20, 20, 31, 0.05);
--overlay-05: rgba(20, 20, 31, 0.06);
--overlay-06: rgba(20, 20, 31, 0.08);
--overlay-08: rgba(20, 20, 31, 0.1);
--overlay-10: rgba(20, 20, 31, 0.12);
--accent-soft-fg: #4f46e5;
color-scheme: light;
}
/* ---- Base Styles ---- */
* {
box-sizing: border-box;
}
body {
background: var(--background);
color: var(--foreground);
font-family: Arial, Helvetica, sans-serif;
font-family: 'Inter', system-ui, -apple-system, sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
/* ---- Scrollbar ---- */
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: var(--overlay-10);
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background: var(--overlay-08);
}
/* ---- Glassmorphism Cards ---- */
.glass-card {
background: var(--glass-bg);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border: 1px solid var(--glass-border);
border-radius: 16px;
transition: all 0.3s ease;
}
.glass-card:hover {
background: var(--overlay-05);
border-color: rgba(99, 102, 241, 0.2);
box-shadow: 0 8px 32px rgba(99, 102, 241, 0.08);
}
.glass-card-static {
background: var(--glass-bg);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border: 1px solid var(--glass-border);
border-radius: 16px;
}
/* ---- Gradient Text ---- */
.gradient-text {
background: linear-gradient(135deg, #6366f1 0%, #a855f7 50%, #ec4899 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
/* ---- Gradient Border ---- */
.gradient-border {
position: relative;
background: var(--card);
border-radius: 16px;
}
.gradient-border::before {
content: '';
position: absolute;
inset: 0;
border-radius: 16px;
padding: 1px;
background: linear-gradient(135deg, rgba(99, 102, 241, 0.3), rgba(168, 85, 247, 0.3), rgba(236, 72, 153, 0.1));
mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
mask-composite: exclude;
-webkit-mask-composite: xor;
pointer-events: none;
}
/* ---- Glow Effects ---- */
.glow-purple {
box-shadow: 0 0 40px rgba(99, 102, 241, 0.15), 0 0 80px rgba(99, 102, 241, 0.05);
}
.glow-success {
box-shadow: 0 0 20px rgba(34, 197, 94, 0.15);
}
.glow-warning {
box-shadow: 0 0 20px rgba(245, 158, 11, 0.15);
}
.glow-danger {
box-shadow: 0 0 20px rgba(239, 68, 68, 0.15);
}
/* ---- Animations ---- */
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes slideInRight {
from {
opacity: 0;
transform: translateX(20px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
@keyframes pulse-glow {
0%, 100% {
box-shadow: 0 0 20px rgba(99, 102, 241, 0.1);
}
50% {
box-shadow: 0 0 40px rgba(99, 102, 241, 0.25);
}
}
@keyframes shimmer {
0% {
background-position: -200% 0;
}
100% {
background-position: 200% 0;
}
}
@keyframes spin-slow {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
.animate-fade-in-up {
animation: fadeInUp 0.5s ease forwards;
}
.animate-fade-in {
animation: fadeIn 0.3s ease forwards;
}
.animate-slide-in-right {
animation: slideInRight 0.4s ease forwards;
}
.animate-pulse-glow {
animation: pulse-glow 3s ease-in-out infinite;
}
.animate-spin-slow {
animation: spin-slow 2s linear infinite;
}
/* Staggered animation delays */
.delay-1 { animation-delay: 0.1s; opacity: 0; }
.delay-2 { animation-delay: 0.2s; opacity: 0; }
.delay-3 { animation-delay: 0.3s; opacity: 0; }
.delay-4 { animation-delay: 0.4s; opacity: 0; }
.delay-5 { animation-delay: 0.5s; opacity: 0; }
.delay-6 { animation-delay: 0.6s; opacity: 0; }
/* ---- Shimmer Loading ---- */
.shimmer {
background: linear-gradient(
90deg,
var(--overlay-03) 25%,
var(--overlay-08) 50%,
var(--overlay-03) 75%
);
background-size: 200% 100%;
animation: shimmer 1.5s infinite;
border-radius: 8px;
}
/* ---- Background Pattern ---- */
.bg-grid-pattern {
background-image:
linear-gradient(var(--grid-line) 1px, transparent 1px),
linear-gradient(90deg, var(--grid-line) 1px, transparent 1px);
background-size: 60px 60px;
}
/* ---- Status Badges ---- */
.badge-active {
background: rgba(34, 197, 94, 0.1);
color: #4ade80;
border: 1px solid rgba(34, 197, 94, 0.2);
}
.badge-paused {
background: rgba(245, 158, 11, 0.1);
color: #fbbf24;
border: 1px solid rgba(245, 158, 11, 0.2);
}
.badge-deleted {
background: rgba(239, 68, 68, 0.1);
color: #f87171;
border: 1px solid rgba(239, 68, 68, 0.2);
}
/* ---- Table Styles ---- */
.campaign-table th {
padding: 12px 16px;
text-align: left;
font-weight: 500;
font-size: 12px;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--muted-fg);
border-bottom: 1px solid var(--overlay-06);
white-space: nowrap;
}
.campaign-table td {
padding: 14px 16px;
font-size: 14px;
border-bottom: 1px solid var(--overlay-04);
white-space: nowrap;
}
.campaign-table tbody tr {
transition: background 0.2s ease;
}
.campaign-table tbody tr:hover {
background: rgba(99, 102, 241, 0.04);
}
/* ---- Button Styles ---- */
.btn-primary {
background: linear-gradient(135deg, #6366f1, #8b5cf6);
color: white;
font-weight: 600;
padding: 10px 20px;
border-radius: 10px;
border: none;
cursor: pointer;
transition: all 0.3s ease;
font-size: 14px;
}
.btn-primary:hover {
transform: translateY(-1px);
box-shadow: 0 8px 24px rgba(99, 102, 241, 0.3);
}
.btn-primary:active {
transform: translateY(0);
}
.btn-primary:disabled {
opacity: 0.5;
cursor: not-allowed;
transform: none;
box-shadow: none;
}
.btn-ghost {
background: transparent;
color: var(--muted-fg);
font-weight: 500;
padding: 8px 14px;
border-radius: 8px;
border: 1px solid var(--overlay-08);
cursor: pointer;
transition: all 0.2s ease;
font-size: 13px;
}
.btn-ghost:hover {
background: var(--overlay-05);
color: var(--foreground);
border-color: var(--overlay-10);
}
.btn-danger {
background: rgba(239, 68, 68, 0.1);
color: #f87171;
font-weight: 500;
padding: 8px 14px;
border-radius: 8px;
border: 1px solid rgba(239, 68, 68, 0.2);
cursor: pointer;
transition: all 0.2s ease;
font-size: 13px;
}
.btn-danger:hover {
background: rgba(239, 68, 68, 0.2);
}
.btn-success {
background: rgba(34, 197, 94, 0.1);
color: #4ade80;
font-weight: 500;
padding: 8px 14px;
border-radius: 8px;
border: 1px solid rgba(34, 197, 94, 0.2);
cursor: pointer;
transition: all 0.2s ease;
font-size: 13px;
}
.btn-success:hover {
background: rgba(34, 197, 94, 0.2);
}
/* ---- Input Styles ---- */
.input-field {
background: var(--input-bg);
border: 1px solid var(--input-border);
border-radius: 10px;
padding: 12px 16px;
color: var(--foreground);
font-size: 14px;
font-family: 'Inter', sans-serif;
outline: none;
transition: all 0.2s ease;
width: 100%;
}
.input-field:focus {
border-color: var(--accent);
box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.15);
}
.input-field::placeholder {
color: var(--muted-fg);
}
/* ---- Modal Overlay ---- */
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.6);
backdrop-filter: blur(8px);
display: flex;
align-items: center;
justify-content: center;
z-index: 50;
animation: fadeIn 0.2s ease;
}
.modal-content {
background: var(--modal-bg);
border: 1px solid var(--overlay-08);
border-radius: 20px;
padding: 32px;
max-width: 480px;
width: 90%;
animation: fadeInUp 0.3s ease;
}
/* ---- Autofill Override ---- */
input:-webkit-autofill,
input:-webkit-autofill:hover,
input:-webkit-autofill:focus,
input:-webkit-autofill:active {
-webkit-box-shadow: 0 0 0 30px var(--modal-bg) inset !important;
-webkit-text-fill-color: var(--foreground) !important;
caret-color: var(--foreground) !important;
transition: background-color 5000s ease-in-out 0s;
}
/* ---- Theme-aware overrides for literal Tailwind white/black utilities ----
The dashboard was originally designed dark-only, using `text-white`,
`bg-white/X`, `border-white/X` and `bg-black/X` utilities as shorthand for
"foreground on glass surface". In light mode these need to resolve against
the inverted palette remapped here so existing markup adapts without a
full rewrite. */
[data-theme='light'] [class*="text-white"] {
color: var(--foreground) !important;
}
[data-theme='light'] [class*="placeholder-white"]::placeholder,
[data-theme='light'] [class*="placeholder:text-white"]::placeholder {
color: var(--muted-fg) !important;
}
[data-theme='light'] [class*="bg-white/"],
[data-theme='light'] [class*="hover:bg-white/"]:hover {
background-color: var(--overlay-04) !important;
}
[data-theme='light'] [class*="border-white/"] {
border-color: var(--overlay-08) !important;
}
[data-theme='light'] [class*="bg-black/"] {
background-color: var(--overlay-06) !important;
}
[data-theme='light'] [class*="border-black/"] {
border-color: var(--overlay-10) !important;
}

View file

@ -1,20 +1,11 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});
import { ThemeProvider, themeInitScript } from "@/components/theme/ThemeProvider";
export const metadata: Metadata = {
title: "Create Next App",
description: "Generated by create next app",
title: "MetaAds Pro — Dashboard de Facebook Ads",
description: "Gerencie suas campanhas de Facebook Ads em tempo real. Veja métricas de Spend, CTR, CPC, CPM, Leads e ROAS. Pause, reative e altere orçamentos.",
icons: { icon: "/favicon.ico" },
};
export default function RootLayout({
@ -23,11 +14,13 @@ export default function RootLayout({
children: React.ReactNode;
}>) {
return (
<html
lang="en"
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
>
<body className="min-h-full flex flex-col">{children}</body>
<html lang="pt-BR" className="h-full antialiased" suppressHydrationWarning>
<head>
<script dangerouslySetInnerHTML={{ __html: themeInitScript }} />
</head>
<body className="min-h-full flex flex-col" suppressHydrationWarning>
<ThemeProvider>{children}</ThemeProvider>
</body>
</html>
);
}

96
src/app/login/page.tsx Normal file
View file

@ -0,0 +1,96 @@
'use client';
import React, { Suspense } from 'react';
import { Zap } from 'lucide-react';
import { useSearchParams } from 'next/navigation';
function LoginContent() {
const searchParams = useSearchParams();
const error = searchParams.get('error');
return (
<div className="min-h-screen flex items-center justify-center relative overflow-hidden" style={{ background: 'var(--background)' }}>
{/* Background Effects */}
<div className="absolute inset-0 bg-grid-pattern opacity-30" />
<div className="absolute top-[-200px] left-[-200px] w-[500px] h-[500px] rounded-full" style={{ background: 'radial-gradient(circle, rgba(99,102,241,0.08) 0%, transparent 70%)' }} />
<div className="absolute bottom-[-200px] right-[-200px] w-[500px] h-[500px] rounded-full" style={{ background: 'radial-gradient(circle, rgba(168,85,247,0.06) 0%, transparent 70%)' }} />
<div className="relative z-10 w-full max-w-md px-6">
{/* Logo */}
<div className="text-center mb-10 animate-fade-in-up">
<div className="inline-flex items-center justify-center w-16 h-16 rounded-2xl mb-5" style={{ background: 'linear-gradient(135deg, #6366f1, #8b5cf6)', boxShadow: '0 8px 32px rgba(99,102,241,0.3)' }}>
<Zap size={30} className="text-white" />
</div>
<h1 className="text-3xl font-bold text-white mb-1">MetaAds <span className="gradient-text">Pro</span></h1>
<p className="text-sm" style={{ color: 'var(--muted-fg)' }}>Gerencie suas campanhas com IA autônoma</p>
</div>
{/* Card */}
<div className="gradient-border animate-fade-in-up delay-1">
<div className="p-8" style={{ background: 'var(--modal-bg)', backdropFilter: 'blur(20px)', borderRadius: '16px' }}>
<h2 className="text-lg font-semibold text-white mb-2">Conecte sua conta Meta</h2>
<p className="text-sm mb-6" style={{ color: 'var(--muted-fg)' }}>
Faça login com sua conta do Facebook para acessar suas campanhas via MCP oficial da Meta.
</p>
{error && (
<div className="px-4 py-3 rounded-xl text-sm mb-4 animate-fade-in" style={{ background: 'rgba(239,68,68,0.1)', border: '1px solid rgba(239,68,68,0.2)', color: '#f87171' }}>
{error}
</div>
)}
{/* Meta Login Button */}
<a
href="/api/auth/meta"
className="w-full flex items-center justify-center gap-3 py-3.5 rounded-xl font-semibold text-white transition-all duration-300 hover:brightness-110 hover:scale-[1.01] active:scale-[0.99]"
style={{
background: 'linear-gradient(135deg, #1877F2, #0C63D4)',
boxShadow: '0 4px 20px rgba(24,119,242,0.3)',
}}
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="white">
<path d="M24 12.073c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.99 4.388 10.954 10.125 11.854v-8.385H7.078v-3.47h3.047V9.43c0-3.007 1.792-4.669 4.533-4.669 1.312 0 2.686.235 2.686.235v2.953H15.83c-1.491 0-1.956.925-1.956 1.874v2.25h3.328l-.532 3.47h-2.796v8.385C19.612 23.027 24 18.062 24 12.073z"/>
</svg>
Entrar com Facebook
</a>
{/* Info */}
<div className="mt-6 p-4 rounded-xl" style={{ background: 'var(--overlay-03)', border: '1px solid var(--overlay-06)' }}>
<p className="text-[11px] leading-relaxed mb-3" style={{ color: 'var(--muted-fg)' }}>
🔐 <strong className="text-white">Conexão segura via OAuth</strong> Seus dados nunca são armazenados.
Usamos o MCP oficial da Meta para acessar suas campanhas com as permissões que você autorizar.
</p>
<div className="pt-3 border-t border-white/5 flex justify-between items-center">
<span className="text-[10px]" style={{ color: 'var(--muted-fg)' }}>Primeiro acesso?</span>
<a href="/setup" className="text-[11px] font-medium text-indigo-400 hover:text-indigo-300">
Configurar Credenciais do App
</a>
</div>
</div>
{/* Features */}
<div className="mt-5 grid grid-cols-3 gap-2">
{['Monitoramento IA', 'Criativos com IA', 'Auto-Otimização'].map((f) => (
<div key={f} className="text-center py-2 px-1 rounded-lg" style={{ background: 'rgba(99,102,241,0.05)', border: '1px solid rgba(99,102,241,0.1)' }}>
<span className="text-[10px] font-medium" style={{ color: 'var(--accent-soft-fg)' }}>{f}</span>
</div>
))}
</div>
</div>
</div>
<p className="text-center mt-8 text-xs" style={{ color: 'var(--muted-fg)' }}>
© 2026 MetaAds Pro. Powered by Meta MCP + OpenAI.
</p>
</div>
</div>
);
}
export default function LoginPage() {
return (
<Suspense fallback={<div className="min-h-screen" style={{ background: 'var(--background)' }} />}>
<LoginContent />
</Suspense>
);
}

View file

@ -1,65 +1,209 @@
import Image from "next/image";
'use client';
export default function Home() {
import Link from 'next/link';
import { motion } from 'framer-motion';
import { Wand2, Activity, Shield, History, ArrowRight, Sparkles } from 'lucide-react';
import { PublicHeader } from '@/components/public/PublicHeader';
import { PublicFooter } from '@/components/public/PublicFooter';
import { FloatingOrbs } from '@/components/public/FloatingOrbs';
import { LiveChartMock } from '@/components/public/LiveChartMock';
import { HowItWorks } from '@/components/public/HowItWorks';
import { StatsStrip } from '@/components/public/StatsStrip';
import { ScrollReveal } from '@/components/public/ScrollReveal';
const features = [
{
icon: Wand2,
title: 'Criação de campanhas com IA',
description: 'Cole o link do seu site (ou descreva seu produto) e a IA detecta o nicho, monta a copy, gera imagens e configura toda a campanha sozinha.',
},
{
icon: Activity,
title: 'Auditoria automática de anúncios',
description: 'A IA lê suas campanhas ativas, analisa métricas (CTR, CPC, ROAS) e sugere os próximos passos com linguagem clara, sem jargão.',
},
{
icon: Shield,
title: 'Automação por regras',
description: 'Defina regras de orçamento e performance — pausar, escalar ou ajustar lances automaticamente conforme os resultados chegam.',
},
{
icon: History,
title: 'Histórico que nunca se perde',
description: 'Toda copy, criativo e configuração gerados ficam salvos — mesmo se a publicação falhar, você retoma de onde parou.',
},
];
const fadeUp = {
hidden: { opacity: 0, y: 24 },
show: (i: number) => ({
opacity: 1,
y: 0,
transition: { duration: 0.7, delay: 0.1 + i * 0.12, ease: 'easeOut' as const },
}),
};
export default function LandingPage() {
return (
<div className="flex flex-col flex-1 items-center justify-center bg-zinc-50 font-sans dark:bg-black">
<main className="flex flex-1 w-full max-w-3xl flex-col items-center justify-between py-32 px-16 bg-white dark:bg-black sm:items-start">
<Image
className="dark:invert"
src="/next.svg"
alt="Next.js logo"
width={100}
height={20}
priority
/>
<div className="flex flex-col items-center gap-6 text-center sm:items-start sm:text-left">
<h1 className="max-w-xs text-3xl font-semibold leading-10 tracking-tight text-black dark:text-zinc-50">
To get started, edit the page.tsx file.
</h1>
<p className="max-w-md text-lg leading-8 text-zinc-600 dark:text-zinc-400">
Looking for a starting point or more instructions? Head over to{" "}
<a
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
className="font-medium text-zinc-950 dark:text-zinc-50"
<div className="min-h-screen flex flex-col" style={{ background: 'var(--background)', color: 'var(--foreground)' }}>
<PublicHeader />
<main className="flex-1">
{/* Hero */}
<section className="relative overflow-hidden">
<div className="absolute inset-0 bg-grid-pattern opacity-30" />
<FloatingOrbs />
<div className="relative z-10 max-w-6xl mx-auto px-6 py-20 md:py-28 grid lg:grid-cols-[1.1fr_0.9fr] gap-14 items-center">
<div className="text-center lg:text-left">
<motion.div
custom={0}
initial="hidden"
animate="show"
variants={fadeUp}
className="inline-flex items-center gap-2 px-4 py-1.5 rounded-full text-xs font-medium mb-6"
style={{ background: 'var(--overlay-04)', border: '1px solid var(--overlay-08)', color: 'var(--accent-hover)' }}
>
Templates
</a>{" "}
or the{" "}
<a
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
className="font-medium text-zinc-950 dark:text-zinc-50"
<motion.span
animate={{ rotate: [0, 15, -10, 0] }}
transition={{ duration: 2.4, repeat: Infinity, ease: 'easeInOut' }}
className="inline-flex"
>
Learning
</a>{" "}
center.
<Sparkles size={14} />
</motion.span>
Tráfego pago no Meta, no piloto automático
</motion.div>
<motion.h1
custom={1}
initial="hidden"
animate="show"
variants={fadeUp}
className="text-4xl md:text-6xl font-bold leading-tight mb-6"
>
Crie, publique e otimize campanhas no <span className="gradient-text">Meta Ads</span> com IA autônoma
</motion.h1>
<motion.p
custom={2}
initial="hidden"
animate="show"
variants={fadeUp}
className="text-base md:text-lg max-w-xl mx-auto lg:mx-0 mb-10"
style={{ color: 'var(--muted-fg)' }}
>
O MetaAds Pro conecta direto com sua conta do Facebook Ads, entende seu produto a partir de um link
ou descrição, e monta campanhas profissionais copy, criativos, segmentação e configuração sem você
precisar preencher um formulário gigante.
</motion.p>
<motion.div
custom={3}
initial="hidden"
animate="show"
variants={fadeUp}
className="flex flex-col sm:flex-row items-center justify-center lg:justify-start gap-4"
>
<Link href="/login" className="group">
<motion.span
whileHover={{ scale: 1.04 }}
whileTap={{ scale: 0.97 }}
className="flex items-center gap-2 px-6 py-3.5 rounded-xl font-semibold text-white transition-shadow duration-300"
style={{ background: 'linear-gradient(135deg, #6366f1, #8b5cf6)', boxShadow: '0 8px 32px rgba(99,102,241,0.3)' }}
>
Começar agora
<ArrowRight size={18} className="transition-transform group-hover:translate-x-1" />
</motion.span>
</Link>
<Link href="#features">
<motion.span
whileHover={{ scale: 1.04 }}
whileTap={{ scale: 0.97 }}
className="inline-flex px-6 py-3.5 rounded-xl font-medium"
style={{ background: 'var(--overlay-04)', border: '1px solid var(--overlay-08)', color: 'var(--foreground)' }}
>
Ver como funciona
</motion.span>
</Link>
</motion.div>
</div>
<div className="flex justify-center lg:justify-end">
<LiveChartMock />
</div>
</div>
</section>
<StatsStrip />
<HowItWorks />
{/* Features */}
<section id="features" className="max-w-6xl mx-auto px-6 py-20">
<ScrollReveal className="text-center mb-14">
<h2 className="text-2xl md:text-3xl font-bold mb-3">Tudo que você precisa para escalar no Meta Ads</h2>
<p className="text-sm md:text-base max-w-xl mx-auto" style={{ color: 'var(--muted-fg)' }}>
Conectado via integração oficial da Meta você mantém o controle total da sua conta de anúncios.
</p>
</div>
<div className="flex flex-col gap-4 text-base font-medium sm:flex-row">
<a
className="flex h-12 w-full items-center justify-center gap-2 rounded-full bg-foreground px-5 text-background transition-colors hover:bg-[#383838] dark:hover:bg-[#ccc] md:w-[158px]"
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
</ScrollReveal>
<div className="grid sm:grid-cols-2 gap-5">
{features.map((feature, i) => {
const Icon = feature.icon;
return (
<ScrollReveal key={feature.title} delay={i * 0.1}>
<motion.div
whileHover={{ y: -4 }}
transition={{ type: 'spring', stiffness: 300, damping: 20 }}
className="glass-card p-6 h-full"
>
<Image
className="dark:invert"
src="/vercel.svg"
alt="Vercel logomark"
width={16}
height={16}
<div className="w-11 h-11 rounded-xl flex items-center justify-center mb-4" style={{ background: 'rgba(99,102,241,0.12)', border: '1px solid rgba(99,102,241,0.2)' }}>
<Icon size={20} style={{ color: 'var(--accent-soft-fg)' }} />
</div>
<h3 className="text-base font-semibold mb-2" style={{ color: 'var(--foreground)' }}>{feature.title}</h3>
<p className="text-sm leading-relaxed" style={{ color: 'var(--muted-fg)' }}>{feature.description}</p>
</motion.div>
</ScrollReveal>
);
})}
</div>
</section>
{/* CTA */}
<section className="max-w-4xl mx-auto px-6 pb-24">
<ScrollReveal>
<div className="gradient-border relative overflow-hidden">
<motion.div
className="absolute inset-0 opacity-40"
style={{ background: 'radial-gradient(circle at 30% 20%, rgba(99,102,241,0.25), transparent 60%)' }}
animate={{ opacity: [0.25, 0.5, 0.25] }}
transition={{ duration: 4, repeat: Infinity, ease: 'easeInOut' }}
/>
Deploy Now
</a>
<a
className="flex h-12 w-full items-center justify-center rounded-full border border-solid border-black/[.08] px-5 transition-colors hover:border-transparent hover:bg-black/[.04] dark:border-white/[.145] dark:hover:bg-[#1a1a1a] md:w-[158px]"
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
<div className="relative p-10 text-center rounded-2xl" style={{ background: 'var(--card)', backdropFilter: 'blur(20px)' }}>
<h2 className="text-2xl font-bold mb-3">Pronto para automatizar suas campanhas?</h2>
<p className="text-sm mb-7 max-w-lg mx-auto" style={{ color: 'var(--muted-fg)' }}>
Conecte sua conta do Facebook em segundos e deixe a IA cuidar do trabalho pesado da estratégia à publicação.
</p>
<Link href="/login">
<motion.span
whileHover={{ scale: 1.04 }}
whileTap={{ scale: 0.97 }}
className="inline-flex items-center gap-2 px-6 py-3.5 rounded-xl font-semibold text-white"
style={{ background: 'linear-gradient(135deg, #1877F2, #0C63D4)', boxShadow: '0 4px 20px rgba(24,119,242,0.3)' }}
>
Documentation
</a>
<svg width="18" height="18" viewBox="0 0 24 24" fill="white">
<path d="M24 12.073c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.99 4.388 10.954 10.125 11.854v-8.385H7.078v-3.47h3.047V9.43c0-3.007 1.792-4.669 4.533-4.669 1.312 0 2.686.235 2.686.235v2.953H15.83c-1.491 0-1.956.925-1.956 1.874v2.25h3.328l-.532 3.47h-2.796v8.385C19.612 23.027 24 18.062 24 12.073z"/>
</svg>
Entrar com Facebook
</motion.span>
</Link>
</div>
</div>
</ScrollReveal>
</section>
</main>
<PublicFooter />
</div>
);
}

85
src/app/privacy/page.tsx Normal file
View file

@ -0,0 +1,85 @@
import { LegalPage, LegalSection } from '@/components/public/LegalPage';
export const metadata = {
title: 'Política de Privacidade — MetaAds Pro',
};
export default function PrivacyPage() {
return (
<LegalPage title="Política de Privacidade" updatedAt="07 de junho de 2026">
<p>
Esta Política de Privacidade descreve como o <strong style={{ color: 'var(--foreground)' }}>MetaAds Pro</strong> ("nós", "nosso" ou "aplicativo")
coleta, usa e protege as informações de quem conecta sua conta do Facebook/Meta para gerenciar campanhas
de anúncios através da nossa plataforma.
</p>
<LegalSection title="1. Quem somos">
<p>
O MetaAds Pro é uma ferramenta que se conecta à sua conta de anúncios do Meta (Facebook/Instagram) por meio
da integração oficial OAuth/Marketing API, permitindo criar, monitorar e otimizar campanhas publicitárias
com o auxílio de inteligência artificial.
</p>
</LegalSection>
<LegalSection title="2. Quais dados coletamos">
<p>Coletamos apenas o necessário para operar o aplicativo:</p>
<ul className="list-disc pl-5 flex flex-col gap-1">
<li>Informações básicas do seu perfil do Facebook (nome e foto), fornecidas pelo login OAuth da Meta;</li>
<li>O token de acesso OAuth necessário para realizar chamadas autorizadas à API de Marketing da Meta em seu nome;</li>
<li>Dados das suas campanhas, conjuntos de anúncios, anúncios e métricas de desempenho (gasto, CTR, CPC, CPM, conversões), obtidos diretamente da sua conta de anúncios;</li>
<li>Conteúdo gerado por você ou pela IA durante o uso (textos de campanha, imagens, configurações de público e orçamento), salvo para que você possa retomar o trabalho sem perdê-lo.</li>
</ul>
</LegalSection>
<LegalSection title="3. Como usamos os dados">
<ul className="list-disc pl-5 flex flex-col gap-1">
<li>Para autenticar você e manter sua sessão ativa no painel;</li>
<li>Para ler e gerenciar suas campanhas de anúncios conforme as ações que você solicitar (criar, pausar, ajustar orçamento, etc.);</li>
<li>Para gerar sugestões, textos publicitários, imagens e diagnósticos com inteligência artificial, com base no contexto do seu negócio;</li>
<li>Para salvar o histórico de campanhas e criativos gerados, evitando que você precise refazer o trabalho em caso de falha na publicação.</li>
</ul>
<p>Não vendemos, alugamos ou compartilhamos seus dados com terceiros para fins de marketing.</p>
</LegalSection>
<LegalSection title="4. Com quem compartilhamos dados">
<p>Seus dados podem ser processados pelos seguintes serviços, estritamente para o funcionamento do aplicativo:</p>
<ul className="list-disc pl-5 flex flex-col gap-1">
<li><strong style={{ color: 'var(--foreground)' }}>Meta (Facebook)</strong> para autenticação OAuth e leitura/gestão das campanhas autorizadas por você;</li>
<li><strong style={{ color: 'var(--foreground)' }}>OpenAI</strong> para geração de textos, imagens e análises com inteligência artificial a partir do contexto que você fornece;</li>
<li><strong style={{ color: 'var(--foreground)' }}>Supabase</strong> para armazenamento seguro do histórico de campanhas e configurações geradas na plataforma.</li>
</ul>
</LegalSection>
<LegalSection title="5. Armazenamento e segurança">
<p>
Os dados são armazenados em infraestrutura com controle de acesso restrito e transmitidos por conexões
criptografadas (HTTPS/TLS). O token de acesso à sua conta Meta é usado exclusivamente para chamadas
autorizadas à API e não é compartilhado com terceiros não mencionados nesta política.
</p>
</LegalSection>
<LegalSection title="6. Seus direitos">
<p>
Você pode, a qualquer momento, revogar o acesso do MetaAds Pro à sua conta do Facebook através das
configurações de aplicativos do próprio Facebook, ou solicitar a exclusão dos dados armazenados em
nossa plataforma veja as instruções na nossa{' '}
<a href="/data-deletion" className="underline" style={{ color: 'var(--accent)' }}>página de exclusão de dados</a>.
</p>
</LegalSection>
<LegalSection title="7. Alterações nesta política">
<p>
Podemos atualizar esta Política de Privacidade periodicamente. Mudanças relevantes serão comunicadas
através do próprio aplicativo. A data da última atualização está sempre indicada no topo desta página.
</p>
</LegalSection>
<LegalSection title="8. Contato">
<p>
Em caso de dúvidas sobre esta política ou sobre como seus dados são tratados, entre em contato pelo
e-mail <a href="mailto:bevervansomarcio@gmail.com" className="underline" style={{ color: 'var(--accent)' }}>bevervansomarcio@gmail.com</a>.
</p>
</LegalSection>
</LegalPage>
);
}

34
src/app/setup/page.tsx Normal file
View file

@ -0,0 +1,34 @@
'use client';
import React from 'react';
import SettingsPage from '@/app/dashboard/settings/page';
import { Zap } from 'lucide-react';
import Link from 'next/link';
export default function SetupRoute() {
return (
<div className="min-h-screen relative overflow-y-auto" style={{ background: 'var(--background)' }}>
{/* Background Effects */}
<div className="fixed inset-0 bg-grid-pattern opacity-30 pointer-events-none" />
<div className="fixed top-[-200px] left-[-200px] w-[500px] h-[500px] rounded-full pointer-events-none" style={{ background: 'radial-gradient(circle, rgba(99,102,241,0.08) 0%, transparent 70%)' }} />
<div className="relative z-10 p-8">
<div className="max-w-3xl mx-auto mb-8 flex items-center justify-between">
<div className="flex items-center gap-2.5">
<div className="w-9 h-9 rounded-xl flex items-center justify-center" style={{ background: 'linear-gradient(135deg, #6366f1, #8b5cf6)' }}>
<Zap size={18} className="text-white" />
</div>
<div>
<h1 className="text-base font-bold text-white leading-tight">MetaAds Pro</h1>
<p className="text-[10px] uppercase tracking-widest font-semibold" style={{ color: 'var(--accent)' }}>Setup Inicial</p>
</div>
</div>
<Link href="/login" className="text-sm font-medium px-4 py-2 rounded-lg" style={{ background: 'var(--overlay-05)', color: 'white' }}>
Voltar para Login
</Link>
</div>
<SettingsPage />
</div>
</div>
);
}

84
src/app/terms/page.tsx Normal file
View file

@ -0,0 +1,84 @@
import { LegalPage, LegalSection } from '@/components/public/LegalPage';
export const metadata = {
title: 'Termos de Uso — MetaAds Pro',
};
export default function TermsPage() {
return (
<LegalPage title="Termos de Uso" updatedAt="07 de junho de 2026">
<p>
Ao acessar ou usar o <strong style={{ color: 'var(--foreground)' }}>MetaAds Pro</strong>, você concorda com os
termos descritos abaixo. Leia com atenção antes de conectar sua conta de anúncios.
</p>
<LegalSection title="1. Sobre o serviço">
<p>
O MetaAds Pro é uma ferramenta de gestão e criação de campanhas publicitárias que se conecta à sua
conta do Meta Ads (Facebook/Instagram) através da API oficial de Marketing da Meta, com o auxílio de
modelos de inteligência artificial para gerar copy, criativos, segmentação e diagnósticos de desempenho.
</p>
</LegalSection>
<LegalSection title="2. Conta e autorização">
<ul className="list-disc pl-5 flex flex-col gap-1">
<li>Você precisa de uma conta do Facebook com acesso a uma conta de anúncios do Meta Business para usar o aplicativo;</li>
<li>Ao autorizar a conexão via OAuth, você concede ao MetaAds Pro permissão para ler e gerenciar campanhas em seu nome, conforme o escopo de permissões exibido no momento do login;</li>
<li>Você é responsável por manter a confidencialidade da sua sessão e por todas as ações realizadas através da sua conta autenticada.</li>
</ul>
</LegalSection>
<LegalSection title="3. Uso da inteligência artificial">
<p>
O conteúdo gerado por IA (textos, imagens, sugestões de segmentação e diagnósticos) é produzido com
base nas informações que você fornece (links, descrições, métricas da sua conta). Esse conteúdo é uma
sugestão você é responsável por revisar e aprovar qualquer campanha antes de publicá-la, garantindo
que ela esteja em conformidade com as Políticas de Publicidade da Meta e com a legislação aplicável
(incluindo, no Brasil, o Código de Defesa do Consumidor e a LGPD).
</p>
</LegalSection>
<LegalSection title="4. Gastos com anúncios">
<p>
O MetaAds Pro <strong style={{ color: 'var(--foreground)' }}>não processa pagamentos nem tem acesso aos seus
métodos de cobrança</strong>. Toda publicação de campanha gera gastos diretamente na sua conta de anúncios
do Meta, de acordo com o orçamento que você definir. Recomendamos revisar orçamento, segmentação e
criativos antes de confirmar a publicação de qualquer campanha.
</p>
</LegalSection>
<LegalSection title="5. Limitação de responsabilidade">
<p>
O MetaAds Pro é fornecido "como está". Não garantimos resultados específicos de desempenho de
campanhas (cliques, conversões, ROAS), pois esses resultados dependem de fatores fora do nosso
controle, como leilões de anúncios, políticas da Meta, qualidade do produto anunciado e comportamento
do público. Não nos responsabilizamos por gastos publicitários decorrentes de campanhas publicadas
por você através da plataforma.
</p>
</LegalSection>
<LegalSection title="6. Suspensão e encerramento">
<p>
Você pode parar de usar o MetaAds Pro e revogar o acesso à sua conta a qualquer momento, diretamente
nas configurações de aplicativos conectados do Facebook. Podemos suspender o acesso de contas que
violem estes termos ou as políticas da Meta.
</p>
</LegalSection>
<LegalSection title="7. Alterações nestes termos">
<p>
Podemos atualizar estes Termos de Uso periodicamente para refletir mudanças no funcionamento do
aplicativo ou em requisitos legais/regulatórios. A data da última atualização está sempre indicada
no topo desta página.
</p>
</LegalSection>
<LegalSection title="8. Contato">
<p>
Dúvidas sobre estes termos podem ser enviadas para{' '}
<a href="mailto:bevervansomarcio@gmail.com" className="underline" style={{ color: 'var(--accent)' }}>bevervansomarcio@gmail.com</a>.
</p>
</LegalSection>
</LegalPage>
);
}

View file

@ -0,0 +1,108 @@
'use client';
import React, { useState } from 'react';
import { Modal } from '@/components/ui/Modal';
import { Spinner } from '@/components/ui/Spinner';
interface BudgetModalProps {
isOpen: boolean;
onClose: () => void;
campaignId: string;
campaignName: string;
currentBudget: number;
onSave: (campaignId: string, newBudget: number) => Promise<void>;
}
export function BudgetModal({
isOpen,
onClose,
campaignId,
campaignName,
currentBudget,
onSave,
}: BudgetModalProps) {
const [budget, setBudget] = useState<string>((currentBudget / 100).toFixed(2));
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const handleSave = async () => {
const value = parseFloat(budget);
if (isNaN(value) || value <= 0) {
setError('Digite um valor válido maior que zero');
return;
}
setLoading(true);
setError('');
try {
await onSave(campaignId, Math.round(value * 100));
onClose();
} catch {
setError('Erro ao atualizar orçamento. Tente novamente.');
} finally {
setLoading(false);
}
};
return (
<Modal isOpen={isOpen} onClose={onClose} title="Alterar Orçamento Diário">
<div>
<p className="text-sm mb-4" style={{ color: 'var(--muted-fg)' }}>
Campanha: <span className="text-white font-medium">{campaignName}</span>
</p>
<label className="block text-xs uppercase tracking-wider font-medium mb-2" style={{ color: 'var(--muted-fg)' }}>
Novo Orçamento Diário (R$)
</label>
<div className="relative">
<span className="absolute left-4 top-1/2 -translate-y-1/2 text-sm" style={{ color: 'var(--muted-fg)' }}>
R$
</span>
<input
type="number"
step="0.01"
min="1"
value={budget}
onChange={(e) => setBudget(e.target.value)}
className="input-field pl-12"
placeholder="0.00"
autoFocus
/>
</div>
{error && (
<p className="text-xs mt-2" style={{ color: 'var(--danger)' }}>{error}</p>
)}
<p className="text-xs mt-3" style={{ color: 'var(--muted-fg)' }}>
Orçamento atual: R$ {(currentBudget / 100).toFixed(2)}
</p>
<div className="flex gap-3 mt-6">
<button
onClick={onClose}
className="btn-ghost flex-1"
disabled={loading}
>
Cancelar
</button>
<button
onClick={handleSave}
className="btn-primary flex-1 flex items-center justify-center gap-2"
disabled={loading}
>
{loading ? (
<>
<Spinner size="sm" />
Salvando...
</>
) : (
'Salvar'
)}
</button>
</div>
</div>
</Modal>
);
}

View file

@ -0,0 +1,265 @@
'use client';
import React, { useState } from 'react';
import { Pause, Play, DollarSign, Search, ArrowUpDown, ChevronDown, ChevronRight, Folder, Layers, Megaphone } from 'lucide-react';
import { Spinner } from '@/components/ui/Spinner';
import { BudgetModal } from './BudgetModal';
import type { CampaignWithInsights } from '@/lib/meta/types';
interface CampaignTableProps {
campaigns: CampaignWithInsights[];
loading: boolean;
onStatusChange: (id: string, s: 'ACTIVE' | 'PAUSED') => Promise<void>;
onBudgetChange: (id: string, b: number) => Promise<void>;
}
type SortField = 'name' | 'status' | 'spend' | 'ctr' | 'cpc' | 'cpm' | 'leads' | 'roas';
function getLeads(c: CampaignWithInsights): number {
const a = c.insights?.actions?.find(
(x) => x.action_type === 'lead' || x.action_type === 'onsite_conversion.lead_grouped'
);
return a ? parseInt(a.value, 10) : 0;
}
function getRoas(c: CampaignWithInsights): number {
return c.insights?.purchase_roas?.[0] ? parseFloat(c.insights.purchase_roas[0].value) : 0;
}
export function CampaignTable({ campaigns, loading, onStatusChange, onBudgetChange }: CampaignTableProps) {
const [search, setSearch] = useState('');
const [sortField, setSortField] = useState<SortField>('spend');
const [sortDir, setSortDir] = useState<'asc' | 'desc'>('desc');
const [actionLoading, setActionLoading] = useState<string | null>(null);
const [budgetModal, setBudgetModal] = useState<{ campaignId: string; campaignName: string; currentBudget: number } | null>(null);
// State for tracking which rows (Campaign or AdSet) are expanded
const [expandedRows, setExpandedRows] = useState<Set<string>>(new Set());
const toggleSort = (f: SortField) => {
if (sortField === f) setSortDir(d => d === 'asc' ? 'desc' : 'asc');
else { setSortField(f); setSortDir('desc'); }
};
const toggleStatus = async (id: string, cur: string) => {
setActionLoading(id);
try { await onStatusChange(id, cur === 'ACTIVE' ? 'PAUSED' : 'ACTIVE'); } finally { setActionLoading(null); }
};
const toggleExpand = (id: string) => {
setExpandedRows(prev => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
};
const filtered = campaigns.filter(c => c.campaign.name.toLowerCase().includes(search.toLowerCase()));
const sorted = [...filtered].sort((a, b) => {
const d = sortDir === 'asc' ? 1 : -1;
const val = (field: SortField) => {
switch (field) {
case 'name': return a.campaign.name.localeCompare(b.campaign.name);
case 'status': return a.campaign.status.localeCompare(b.campaign.status);
case 'spend': return parseFloat(a.insights?.spend || '0') - parseFloat(b.insights?.spend || '0');
case 'ctr': return parseFloat(a.insights?.ctr || '0') - parseFloat(b.insights?.ctr || '0');
case 'cpc': return parseFloat(a.insights?.cpc || '0') - parseFloat(b.insights?.cpc || '0');
case 'cpm': return parseFloat(a.insights?.cpm || '0') - parseFloat(b.insights?.cpm || '0');
case 'leads': return getLeads(a) - getLeads(b);
case 'roas': return getRoas(a) - getRoas(b);
default: return 0;
}
};
return val(sortField) * d;
});
if (loading) return (
<div className="glass-card-static p-8 animate-fade-in">
<div className="flex items-center justify-center gap-3 py-12">
<Spinner size="lg" className="text-[var(--accent)]" />
<span className="text-[var(--muted-fg)]">Carregando campanhas...</span>
</div>
</div>
);
const SortBtn = ({ field, label }: { field: SortField; label: string }) => (
<button className="flex items-center gap-1 hover:text-white transition-colors" onClick={() => toggleSort(field)}>
{label} <ArrowUpDown size={12} />
</button>
);
return (
<>
<div className="glass-card-static overflow-hidden animate-fade-in-up delay-3">
<div className="flex items-center justify-between p-5 border-b" style={{ borderColor: 'var(--overlay-06)' }}>
<h2 className="text-base font-semibold text-white">Campanhas</h2>
<div className="relative">
<Search size={14} className="absolute left-3 top-1/2 -translate-y-1/2" style={{ color: 'var(--muted-fg)' }} />
<input type="text" value={search} onChange={e => setSearch(e.target.value)} placeholder="Buscar campanha..." className="input-field text-xs" style={{ width: '220px', paddingLeft: '34px', paddingTop: '8px', paddingBottom: '8px' }} />
</div>
</div>
<div className="overflow-x-auto">
<table className="w-full campaign-table">
<thead>
<tr>
<th className="pl-5">Nome</th>
<th><SortBtn field="status" label="Status" /></th>
<th>Orçamento</th>
<th><SortBtn field="spend" label="Spend" /></th>
<th><SortBtn field="ctr" label="CTR" /></th>
<th><SortBtn field="cpc" label="CPC" /></th>
<th><SortBtn field="cpm" label="CPM" /></th>
<th><SortBtn field="leads" label="Leads" /></th>
<th><SortBtn field="roas" label="ROAS" /></th>
<th className="pr-5">Ações</th>
</tr>
</thead>
<tbody>
{sorted.length === 0 ? (
<tr><td colSpan={10} className="text-center py-12"><p className="text-[var(--muted-fg)]">{search ? 'Nenhuma campanha encontrada' : 'Nenhuma campanha ativa'}</p></td></tr>
) : sorted.map(c => {
const leads = getLeads(c);
const roas = getRoas(c);
const active = c.campaign.status === 'ACTIVE';
const isLoading = actionLoading === c.campaign.id;
const isExpanded = expandedRows.has(c.campaign.id);
const hasAdsets = c.campaign.adsets?.data && c.campaign.adsets.data.length > 0;
return (
<React.Fragment key={c.campaign.id}>
{/* CAMPAIGN ROW */}
<tr className="group">
<td className="pl-5">
<div className="flex items-center gap-2">
<button
onClick={() => hasAdsets && toggleExpand(c.campaign.id)}
className={`p-1 rounded hover:bg-white/5 transition-colors ${!hasAdsets ? 'opacity-0 cursor-default' : ''}`}
>
{isExpanded ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
</button>
<Folder size={14} className="text-indigo-400 shrink-0" />
<div>
<p className="font-medium text-white text-sm">{c.campaign.name}</p>
<p className="text-[11px] text-[var(--muted-fg)]">{c.campaign.objective || 'N/A'}</p>
</div>
</div>
</td>
<td>
<span className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-[11px] font-medium ${active ? 'badge-active' : 'badge-paused'}`}>
<span className={`w-1.5 h-1.5 rounded-full ${active ? 'bg-green-400' : 'bg-yellow-400'}`} />
{active ? 'Ativo' : 'Pausado'}
</span>
</td>
<td className="text-sm text-white">
{c.campaign.daily_budget ? `R$ ${(parseInt(c.campaign.daily_budget, 10) / 100).toFixed(2)}` : '—'}
</td>
<td className="text-sm font-medium text-white">R$ {parseFloat(c.insights?.spend || '0').toFixed(2)}</td>
<td className="text-sm text-[var(--muted-fg)]">{parseFloat(c.insights?.ctr || '0').toFixed(2)}%</td>
<td className="text-sm text-[var(--muted-fg)]">R$ {parseFloat(c.insights?.cpc || '0').toFixed(2)}</td>
<td className="text-sm text-[var(--muted-fg)]">R$ {parseFloat(c.insights?.cpm || '0').toFixed(2)}</td>
<td className="text-sm" style={{ color: leads > 0 ? '#4ade80' : 'var(--muted-fg)' }}>{leads}</td>
<td className="text-sm" style={{ color: roas >= 1 ? '#4ade80' : roas > 0 ? '#fbbf24' : 'var(--muted-fg)' }}>{roas > 0 ? `${roas.toFixed(2)}x` : '—'}</td>
<td className="pr-5">
<div className="flex items-center gap-2">
<button onClick={() => toggleStatus(c.campaign.id, c.campaign.status)} disabled={isLoading} className={`p-1.5 rounded-lg transition-all ${active ? 'btn-danger' : 'btn-success'}`} title={active ? 'Pausar' : 'Reativar'}>
{isLoading ? <Spinner size="sm" /> : active ? <Pause size={14} /> : <Play size={14} />}
</button>
{c.campaign.daily_budget && (
<button onClick={() => setBudgetModal({ campaignId: c.campaign.id, campaignName: c.campaign.name, currentBudget: parseInt(c.campaign.daily_budget || '0', 10) })} className="btn-ghost p-1.5" title="Alterar orçamento">
<DollarSign size={14} />
</button>
)}
</div>
</td>
</tr>
{/* AD SETS ROWS */}
{isExpanded && c.campaign.adsets?.data.map(adset => {
const adsetActive = adset.status === 'ACTIVE';
const adsetIsExpanded = expandedRows.has(adset.id);
const hasAds = adset.ads?.data && adset.ads.data.length > 0;
return (
<React.Fragment key={adset.id}>
<tr style={{ background: 'var(--overlay-01)' }}>
<td className="pl-12 py-2">
<div className="flex items-center gap-2">
<button
onClick={() => hasAds && toggleExpand(adset.id)}
className={`p-1 rounded hover:bg-white/5 transition-colors ${!hasAds ? 'opacity-0 cursor-default' : ''}`}
>
{adsetIsExpanded ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
</button>
<Layers size={14} className="text-purple-400 shrink-0" />
<p className="font-medium text-gray-200 text-xs">{adset.name}</p>
</div>
</td>
<td className="py-2">
<span className={`inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full text-[10px] font-medium ${adsetActive ? 'bg-green-500/10 text-green-400' : 'bg-yellow-500/10 text-yellow-400'}`}>
<span className={`w-1 h-1 rounded-full ${adsetActive ? 'bg-green-400' : 'bg-yellow-400'}`} />
{adsetActive ? 'Ativo' : 'Pausado'}
</span>
</td>
<td className="text-xs text-gray-300 py-2">
{adset.daily_budget ? `R$ ${(parseInt(adset.daily_budget, 10) / 100).toFixed(2)}` : '—'}
</td>
<td className="text-xs font-medium text-gray-300 py-2">R$ {parseFloat(adset.insights?.spend || '0').toFixed(2)}</td>
<td className="text-xs text-[var(--muted-fg)] py-2">{parseFloat(adset.insights?.ctr || '0').toFixed(2)}%</td>
<td className="text-xs text-[var(--muted-fg)] py-2">R$ {parseFloat(adset.insights?.cpc || '0').toFixed(2)}</td>
<td className="text-xs text-[var(--muted-fg)] py-2">R$ {parseFloat(adset.insights?.cpm || '0').toFixed(2)}</td>
<td className="text-xs py-2" style={{ color: (adset.insights?.leads || 0) > 0 ? '#4ade80' : 'var(--muted-fg)' }}>{adset.insights?.leads || 0}</td>
<td className="text-xs py-2" style={{ color: (adset.insights?.roas || 0) >= 1 ? '#4ade80' : (adset.insights?.roas || 0) > 0 ? '#fbbf24' : 'var(--muted-fg)' }}>{(adset.insights?.roas || 0) > 0 ? `${(adset.insights?.roas || 0).toFixed(2)}x` : '—'}</td>
<td className="pr-5 py-2">
{/* Simple status toggle for adset */}
<button onClick={() => toggleStatus(adset.id, adset.status)} disabled={actionLoading === adset.id} className="p-1 rounded hover:bg-white/5 transition-colors text-[var(--muted-fg)] hover:text-white" title={adsetActive ? 'Pausar' : 'Reativar'}>
{actionLoading === adset.id ? <Spinner size="sm" /> : adsetActive ? <Pause size={12} /> : <Play size={12} />}
</button>
</td>
</tr>
{/* ADS ROWS */}
{adsetIsExpanded && adset.ads?.data.map(ad => {
const adActive = ad.status === 'ACTIVE';
return (
<tr key={ad.id} style={{ background: 'var(--overlay-02)' }}>
<td className="pl-20 py-1.5">
<div className="flex items-center gap-2">
<Megaphone size={12} className="text-blue-400 shrink-0" />
<p className="text-gray-300 text-xs">{ad.name}</p>
</div>
</td>
<td className="py-1.5">
<span className={`inline-flex items-center gap-1 px-1.5 py-0.5 rounded-md text-[10px] font-medium ${adActive ? 'text-green-400' : 'text-yellow-400'}`}>
{adActive ? 'Ativo' : 'Pausado'}
</span>
</td>
<td className="py-1.5" />
<td className="text-xs font-medium text-gray-300 py-1.5">R$ {parseFloat(ad.insights?.spend || '0').toFixed(2)}</td>
<td className="text-xs text-[var(--muted-fg)] py-1.5">{parseFloat(ad.insights?.ctr || '0').toFixed(2)}%</td>
<td className="text-xs text-[var(--muted-fg)] py-1.5">R$ {parseFloat(ad.insights?.cpc || '0').toFixed(2)}</td>
<td className="text-xs text-[var(--muted-fg)] py-1.5">R$ {parseFloat(ad.insights?.cpm || '0').toFixed(2)}</td>
<td className="text-xs py-1.5" style={{ color: (ad.insights?.leads || 0) > 0 ? '#4ade80' : 'var(--muted-fg)' }}>{ad.insights?.leads || 0}</td>
<td className="text-xs py-1.5" style={{ color: (ad.insights?.roas || 0) >= 1 ? '#4ade80' : (ad.insights?.roas || 0) > 0 ? '#fbbf24' : 'var(--muted-fg)' }}>{(ad.insights?.roas || 0) > 0 ? `${(ad.insights?.roas || 0).toFixed(2)}x` : '—'}</td>
<td className="pr-5 py-1.5">
<button onClick={() => toggleStatus(ad.id, ad.status)} disabled={actionLoading === ad.id} className="p-1 rounded hover:bg-white/5 transition-colors text-[var(--muted-fg)] hover:text-white" title={adActive ? 'Pausar' : 'Reativar'}>
{actionLoading === ad.id ? <Spinner size="sm" /> : adActive ? <Pause size={12} /> : <Play size={12} />}
</button>
</td>
</tr>
);
})}
</React.Fragment>
);
})}
</React.Fragment>
);
})}
</tbody>
</table>
</div>
</div>
{budgetModal && <BudgetModal isOpen={!!budgetModal} onClose={() => setBudgetModal(null)} campaignId={budgetModal.campaignId} campaignName={budgetModal.campaignName} currentBudget={budgetModal.currentBudget} onSave={onBudgetChange} />}
</>
);
}

View file

@ -0,0 +1,40 @@
'use client';
import React from 'react';
import type { DatePreset } from '@/lib/meta/types';
interface DateFilterProps {
selected: DatePreset;
onChange: (preset: DatePreset) => void;
}
const presets: { value: DatePreset; label: string }[] = [
{ value: 'today', label: 'Hoje' },
{ value: 'yesterday', label: 'Ontem' },
{ value: 'last_7d', label: '7 dias' },
{ value: 'last_14d', label: '14 dias' },
{ value: 'last_30d', label: '30 dias' },
{ value: 'this_month', label: 'Este mês' },
{ value: 'lifetime', label: 'Lifetime' },
];
export function DateFilter({ selected, onChange }: DateFilterProps) {
return (
<div className="flex items-center gap-1 p-1 rounded-xl" style={{ background: 'var(--overlay-03)', border: '1px solid var(--overlay-06)' }}>
{presets.map((preset) => (
<button
key={preset.value}
onClick={() => onChange(preset.value)}
className="px-3 py-1.5 rounded-lg text-xs font-medium transition-all duration-200"
style={{
background: selected === preset.value ? 'linear-gradient(135deg, #6366f1, #8b5cf6)' : 'transparent',
color: selected === preset.value ? 'white' : 'var(--muted-fg)',
boxShadow: selected === preset.value ? '0 4px 12px rgba(99, 102, 241, 0.3)' : 'none',
}}
>
{preset.label}
</button>
))}
</div>
);
}

View file

@ -0,0 +1,142 @@
'use client';
import React from 'react';
import {
DollarSign,
Users,
TrendingUp,
Target,
MousePointerClick,
BarChart3,
LucideIcon,
} from 'lucide-react';
interface MetricsCardProps {
title: string;
value: string;
subtitle?: string;
icon: string;
color: 'purple' | 'green' | 'blue' | 'amber' | 'pink' | 'cyan';
delay?: number;
}
const iconMap: Record<string, LucideIcon> = {
dollar: DollarSign,
users: Users,
trending: TrendingUp,
target: Target,
click: MousePointerClick,
chart: BarChart3,
};
const colorMap = {
purple: {
bg: 'rgba(99, 102, 241, 0.1)',
border: 'rgba(99, 102, 241, 0.2)',
text: 'var(--accent-soft-fg)',
icon: '#818cf8',
glow: 'rgba(99, 102, 241, 0.08)',
},
green: {
bg: 'rgba(34, 197, 94, 0.1)',
border: 'rgba(34, 197, 94, 0.2)',
text: '#86efac',
icon: '#4ade80',
glow: 'rgba(34, 197, 94, 0.08)',
},
blue: {
bg: 'rgba(59, 130, 246, 0.1)',
border: 'rgba(59, 130, 246, 0.2)',
text: '#93c5fd',
icon: '#60a5fa',
glow: 'rgba(59, 130, 246, 0.08)',
},
amber: {
bg: 'rgba(245, 158, 11, 0.1)',
border: 'rgba(245, 158, 11, 0.2)',
text: '#fcd34d',
icon: '#fbbf24',
glow: 'rgba(245, 158, 11, 0.08)',
},
pink: {
bg: 'rgba(236, 72, 153, 0.1)',
border: 'rgba(236, 72, 153, 0.2)',
text: '#f9a8d4',
icon: '#f472b6',
glow: 'rgba(236, 72, 153, 0.08)',
},
cyan: {
bg: 'rgba(6, 182, 212, 0.1)',
border: 'rgba(6, 182, 212, 0.2)',
text: '#67e8f9',
icon: '#22d3ee',
glow: 'rgba(6, 182, 212, 0.08)',
},
};
export function MetricsCard({
title,
value,
subtitle,
icon,
color,
delay = 0,
}: MetricsCardProps) {
const IconComponent = iconMap[icon] || BarChart3;
const colors = colorMap[color];
return (
<div
className={`animate-fade-in-up ${delay > 0 ? `delay-${delay}` : ''}`}
style={{
background: colors.bg,
border: `1px solid ${colors.border}`,
borderRadius: '16px',
padding: '24px',
position: 'relative',
overflow: 'hidden',
boxShadow: `0 4px 24px ${colors.glow}`,
}}
>
{/* Decorative glow */}
<div
style={{
position: 'absolute',
top: '-20px',
right: '-20px',
width: '80px',
height: '80px',
borderRadius: '50%',
background: colors.glow,
filter: 'blur(30px)',
}}
/>
<div className="flex items-start justify-between relative z-10">
<div>
<p className="text-xs uppercase tracking-wider font-medium mb-2" style={{ color: 'var(--muted-fg)' }}>
{title}
</p>
<p className="text-2xl font-bold" style={{ color: colors.text }}>
{value}
</p>
{subtitle && (
<p className="text-xs mt-1" style={{ color: 'var(--muted-fg)' }}>
{subtitle}
</p>
)}
</div>
<div
style={{
background: colors.bg,
border: `1px solid ${colors.border}`,
borderRadius: '12px',
padding: '10px',
}}
>
<IconComponent size={20} style={{ color: colors.icon }} />
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,133 @@
'use client';
import React, { useEffect, useState } from 'react';
import { usePathname, useRouter } from 'next/navigation';
import Link from 'next/link';
import { LayoutDashboard, Shield, Activity, Wand2, Settings, LogOut, Zap, History } from 'lucide-react';
import { ThemeToggle } from '@/components/theme/ThemeToggle';
const navItems = [
{ href: '/dashboard', label: 'Dashboard', icon: LayoutDashboard },
{ href: '/dashboard/analysis', label: 'Auditoria IA', icon: Zap },
{ href: '/dashboard/automation', label: 'Automação', icon: Shield },
{ href: '/dashboard/activity', label: 'Atividade', icon: Activity },
{ href: '/dashboard/create', label: 'Criar Campanha', icon: Wand2 },
{ href: '/dashboard/history', label: 'Histórico', icon: History },
{ href: '/dashboard/settings', label: 'Configurações', icon: Settings },
];
function getCookie(name: string): string | null {
if (typeof document === 'undefined') return null;
const match = document.cookie.match(new RegExp(`(^| )${name}=([^;]+)`));
return match ? decodeURIComponent(match[2]) : null;
}
export function Sidebar() {
const pathname = usePathname();
const router = useRouter();
const [user, setUser] = useState<{ name: string; picture: string } | null>(null);
useEffect(() => {
const raw = getCookie('meta_user');
if (raw) {
try { setUser(JSON.parse(raw)); } catch { /* ignore */ }
}
}, []);
const handleLogout = async () => {
await fetch('/api/auth/logout', { method: 'POST' });
router.push('/login');
};
return (
<aside
className="fixed left-0 top-0 h-screen flex flex-col z-40"
style={{
width: '240px',
background: 'var(--sidebar-bg)',
borderRight: '1px solid var(--overlay-06)',
}}
>
{/* Logo */}
<div className="p-6 pb-4 flex items-center justify-between">
<Link href="/dashboard" className="flex items-center gap-2.5">
<div
className="w-9 h-9 rounded-xl flex items-center justify-center"
style={{ background: 'linear-gradient(135deg, #6366f1, #8b5cf6)' }}
>
<Zap size={18} className="text-white" />
</div>
<div>
<h1 className="text-base font-bold text-white leading-tight">MetaAds</h1>
<p className="text-[10px] uppercase tracking-widest font-semibold" style={{ color: 'var(--accent)' }}>PRO</p>
</div>
</Link>
<ThemeToggle />
</div>
{/* Nav */}
<nav className="flex-1 px-3 py-4">
<p className="px-3 mb-2 text-[10px] uppercase tracking-widest font-medium" style={{ color: 'var(--muted-fg)' }}>
Menu
</p>
<div className="flex flex-col gap-1">
{navItems.map((item: any) => {
const isActive = pathname === item.href;
const Icon = item.icon;
return (
<Link
key={item.href}
href={item.disabled ? '#' : item.href}
className={`flex items-center gap-3 px-3 py-2.5 rounded-xl text-sm font-medium transition-all duration-200 ${
item.disabled ? 'opacity-40 cursor-not-allowed' : ''
}`}
style={{
background: isActive ? 'var(--sidebar-hover)' : 'transparent',
color: isActive ? 'var(--accent-soft-fg)' : 'var(--muted-fg)',
borderLeft: isActive ? '2px solid var(--accent)' : '2px solid transparent',
}}
>
<Icon size={18} />
{item.label}
{item.disabled && (
<span className="ml-auto text-[9px] px-1.5 py-0.5 rounded-full" style={{ background: 'var(--overlay-05)', color: 'var(--muted-fg)' }}>
Em breve
</span>
)}
</Link>
);
})}
</div>
</nav>
{/* Account info */}
<div className="p-4 mx-3 mb-3 rounded-xl" style={{ background: 'var(--overlay-03)', border: '1px solid var(--overlay-06)' }}>
<div className="flex items-center gap-2 mb-1">
{user?.picture ? (
<img src={user.picture} alt="" className="w-6 h-6 rounded-full" />
) : (
<div className="w-2 h-2 rounded-full bg-green-400" />
)}
<span className="text-[11px] font-medium text-green-400">
{user ? 'Conectado via Meta' : 'Conectado'}
</span>
</div>
<p className="text-[11px] text-[var(--muted-fg)] truncate">
{user?.name || 'Meta Ads Account'}
</p>
</div>
{/* Logout */}
<div className="p-3 border-t" style={{ borderColor: 'var(--overlay-06)' }}>
<button
onClick={handleLogout}
className="flex items-center gap-3 px-3 py-2.5 rounded-xl text-sm font-medium transition-all w-full hover:bg-white/5"
style={{ color: 'var(--muted-fg)' }}
>
<LogOut size={18} />
Sair
</button>
</div>
</aside>
);
}

View file

@ -0,0 +1,39 @@
'use client';
import { useEffect, useRef } from 'react';
import { motion, useInView, useMotionValue, useSpring } from 'framer-motion';
export function AnimatedCounter({
value,
suffix = '',
prefix = '',
decimals = 0,
}: {
value: number;
suffix?: string;
prefix?: string;
decimals?: number;
}) {
const ref = useRef<HTMLSpanElement>(null);
const isInView = useInView(ref, { once: true, margin: '-80px' });
const motionValue = useMotionValue(0);
const spring = useSpring(motionValue, { duration: 1600, bounce: 0 });
useEffect(() => {
if (isInView) motionValue.set(value);
}, [isInView, value, motionValue]);
useEffect(() => {
return spring.on('change', (latest) => {
if (ref.current) {
ref.current.textContent = `${prefix}${latest.toFixed(decimals)}${suffix}`;
}
});
}, [spring, prefix, suffix, decimals]);
return (
<motion.span ref={ref}>
{prefix}0{suffix}
</motion.span>
);
}

View file

@ -0,0 +1,37 @@
'use client';
import { motion } from 'framer-motion';
const orbs = [
{ size: 520, top: '-220px', left: '-180px', color: 'rgba(99,102,241,0.16)', dur: 22 },
{ size: 460, top: '20%', right: '-200px', color: 'rgba(168,85,247,0.13)', dur: 26 },
{ size: 380, bottom: '-160px', left: '18%', color: 'rgba(236,72,153,0.10)', dur: 30 },
];
export function FloatingOrbs() {
return (
<div className="absolute inset-0 overflow-hidden pointer-events-none" aria-hidden>
{orbs.map((orb, i) => (
<motion.div
key={i}
className="absolute rounded-full"
style={{
width: orb.size,
height: orb.size,
top: orb.top,
left: orb.left,
right: orb.right,
bottom: orb.bottom,
background: `radial-gradient(circle, ${orb.color} 0%, transparent 70%)`,
filter: 'blur(20px)',
}}
animate={{
x: [0, 40, -30, 0],
y: [0, -30, 20, 0],
}}
transition={{ duration: orb.dur, repeat: Infinity, ease: 'easeInOut' }}
/>
))}
</div>
);
}

View file

@ -0,0 +1,130 @@
'use client';
import { useCallback, useEffect, useRef, useState } from 'react';
import { motion, useInView } from 'framer-motion';
import { Link2, Sparkles, Rocket } from 'lucide-react';
import { ScrollReveal } from './ScrollReveal';
const steps = [
{
icon: Link2,
title: 'Cole o link do produto',
description: 'Sem formulário gigante — só a URL do site ou uma descrição rápida do que você vende.',
},
{
icon: Sparkles,
title: 'A IA monta tudo',
description: 'Nicho, copy, criativos, segmentação, orçamento e objetivo (vendas, leads, tráfego) — gerados sozinhos.',
},
{
icon: Rocket,
title: 'Publica e otimiza',
description: 'A campanha vai ao ar direto na sua conta Meta, e a IA acompanha métricas para sugerir ajustes.',
},
];
export function HowItWorks() {
const containerRef = useRef<HTMLDivElement>(null);
const iconRefs = useRef<(HTMLDivElement | null)[]>([]);
const [line, setLine] = useState<{ left: number; width: number; top: number } | null>(null);
const isInView = useInView(containerRef, { once: true, margin: '-80px' });
const measure = useCallback(() => {
const container = containerRef.current;
const first = iconRefs.current[0];
const last = iconRefs.current[steps.length - 1];
if (!container || !first || !last) return;
const containerRect = container.getBoundingClientRect();
const firstRect = first.getBoundingClientRect();
const lastRect = last.getBoundingClientRect();
const left = firstRect.left + firstRect.width / 2 - containerRect.left;
const right = lastRect.left + lastRect.width / 2 - containerRect.left;
const top = firstRect.top + firstRect.height / 2 - containerRect.top;
setLine({ left, width: right - left, top });
}, []);
useEffect(() => {
measure();
window.addEventListener('resize', measure);
const observer = new ResizeObserver(measure);
if (containerRef.current) observer.observe(containerRef.current);
return () => {
window.removeEventListener('resize', measure);
observer.disconnect();
};
}, [measure]);
// The step icons slide up into place via ScrollReveal's `whileInView` transform —
// re-measure on every frame for a moment after the section enters the viewport so
// the line settles on the icons' final resting position, not their pre-animation spot.
useEffect(() => {
if (!isInView) return;
let frame = 0;
const start = performance.now();
const loop = (now: number) => {
measure();
if (now - start < 1200) frame = requestAnimationFrame(loop);
};
frame = requestAnimationFrame(loop);
return () => cancelAnimationFrame(frame);
}, [isInView, measure]);
return (
<section className="max-w-5xl mx-auto px-6 py-20">
<ScrollReveal className="text-center mb-16">
<h2 className="text-2xl md:text-3xl font-bold mb-3">Da ideia ao anúncio no ar, em três passos</h2>
<p className="text-sm md:text-base max-w-xl mx-auto" style={{ color: 'var(--muted-fg)' }}>
Sem jargão, sem planilha a IA cuida da parte chata enquanto você foca no produto.
</p>
</ScrollReveal>
<div ref={containerRef} className="relative grid sm:grid-cols-3 gap-10 sm:gap-6">
{/* connecting line positioned dynamically so it lands exactly on the icon centers,
since the icons are left-aligned within their column on larger screens. */}
{line && (
<div
className="hidden sm:block absolute h-px"
style={{ left: line.left, width: line.width, top: line.top, background: 'var(--hairline)' }}
>
<motion.div
className="h-full origin-left"
style={{ background: 'linear-gradient(90deg, var(--accent), var(--accent-hover))' }}
initial={{ scaleX: 0 }}
whileInView={{ scaleX: 1 }}
viewport={{ once: true, margin: '-100px' }}
transition={{ duration: 1.2, ease: 'easeInOut', delay: 0.3 }}
/>
</div>
)}
{steps.map((step, i) => {
const Icon = step.icon;
return (
<ScrollReveal key={step.title} delay={i * 0.15} className="relative text-center sm:text-left">
<motion.div
ref={(el) => { iconRefs.current[i] = el; }}
className="relative z-10 w-14 h-14 rounded-2xl flex items-center justify-center mb-5 mx-auto sm:mx-0"
style={{ background: 'linear-gradient(135deg, #6366f1, #8b5cf6)', boxShadow: '0 8px 28px rgba(99,102,241,0.35)' }}
whileHover={{ scale: 1.08, rotate: -4 }}
transition={{ type: 'spring', stiffness: 300, damping: 15 }}
>
<Icon size={24} className="text-white" />
<span
className="absolute -top-2.5 -right-2.5 z-20 w-6 h-6 rounded-full flex items-center justify-center text-[11px] font-bold"
style={{ background: 'var(--modal-bg)', border: '1px solid var(--card-border)', color: 'var(--accent-soft-fg)', boxShadow: '0 2px 8px rgba(0,0,0,0.18)' }}
>
{i + 1}
</span>
</motion.div>
<h3 className="text-base font-semibold mb-2" style={{ color: 'var(--foreground)' }}>{step.title}</h3>
<p className="text-sm leading-relaxed" style={{ color: 'var(--muted-fg)' }}>{step.description}</p>
</ScrollReveal>
);
})}
</div>
</section>
);
}

View file

@ -0,0 +1,36 @@
import React from 'react';
import { PublicHeader } from '@/components/public/PublicHeader';
import { PublicFooter } from '@/components/public/PublicFooter';
interface LegalPageProps {
title: string;
updatedAt: string;
children: React.ReactNode;
}
export function LegalPage({ title, updatedAt, children }: LegalPageProps) {
return (
<div className="min-h-screen flex flex-col" style={{ background: 'var(--background)', color: 'var(--foreground)' }}>
<PublicHeader />
<main className="flex-1">
<div className="max-w-3xl mx-auto px-6 py-16">
<h1 className="text-3xl font-bold mb-2">{title}</h1>
<p className="text-xs mb-10" style={{ color: 'var(--muted-fg)' }}>Última atualização: {updatedAt}</p>
<div className="legal-prose flex flex-col gap-6 text-sm leading-relaxed" style={{ color: 'var(--muted-fg)' }}>
{children}
</div>
</div>
</main>
<PublicFooter />
</div>
);
}
export function LegalSection({ title, children }: { title: string; children: React.ReactNode }) {
return (
<section>
<h2 className="text-lg font-semibold mb-2" style={{ color: 'var(--foreground)' }}>{title}</h2>
<div className="flex flex-col gap-2">{children}</div>
</section>
);
}

View file

@ -0,0 +1,130 @@
'use client';
import { motion } from 'framer-motion';
import { TrendingUp, MousePointerClick, Wallet } from 'lucide-react';
const POINTS = [40, 34, 46, 38, 58, 50, 68, 60, 78, 72, 92, 86];
function buildPath(points: number[], width: number, height: number) {
const step = width / (points.length - 1);
return points
.map((p, i) => {
const x = i * step;
const y = height - (p / 100) * height;
return `${i === 0 ? 'M' : 'L'} ${x.toFixed(1)} ${y.toFixed(1)}`;
})
.join(' ');
}
const W = 280;
const H = 110;
const linePath = buildPath(POINTS, W, H);
const areaPath = `${linePath} L ${W} ${H} L 0 ${H} Z`;
const badges = [
{ icon: TrendingUp, label: 'ROAS', value: '4.8x', color: 'var(--success)' },
{ icon: MousePointerClick, label: 'CTR', value: '3.2%', color: 'var(--accent)' },
{ icon: Wallet, label: 'CPC', value: 'R$ 0,42', color: 'var(--info)' },
];
export function LiveChartMock() {
return (
<motion.div
initial={{ opacity: 0, y: 30 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.8, delay: 0.4, ease: 'easeOut' }}
whileHover={{ y: -4, scale: 1.02 }}
className="glass-card relative w-full max-w-sm p-5 select-none"
style={{ boxShadow: '0 24px 60px -12px rgba(99,102,241,0.35)' }}
>
<div className="flex items-center justify-between mb-4">
<div>
<p className="text-[11px] uppercase tracking-widest font-semibold" style={{ color: 'var(--muted-fg)' }}>
Campanha ativa
</p>
<p className="text-sm font-semibold" style={{ color: 'var(--foreground)' }}>
Tênis Runner Vendas
</p>
</div>
<span
className="inline-flex items-center gap-1.5 text-[11px] font-medium px-2.5 py-1 rounded-full"
style={{ background: 'rgba(34,197,94,0.12)', color: 'var(--success)' }}
>
<motion.span
className="w-1.5 h-1.5 rounded-full"
style={{ background: 'var(--success)' }}
animate={{ opacity: [1, 0.3, 1] }}
transition={{ duration: 1.6, repeat: Infinity, ease: 'easeInOut' }}
/>
Otimizando
</span>
</div>
<svg viewBox={`0 0 ${W} ${H}`} className="w-full h-28" preserveAspectRatio="none">
<defs>
<linearGradient id="chartFill" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="var(--accent)" stopOpacity="0.35" />
<stop offset="100%" stopColor="var(--accent)" stopOpacity="0" />
</linearGradient>
</defs>
<motion.path
d={areaPath}
fill="url(#chartFill)"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 1, delay: 1.1 }}
/>
<motion.path
d={linePath}
fill="none"
stroke="var(--accent)"
strokeWidth="2.5"
strokeLinecap="round"
initial={{ pathLength: 0 }}
animate={{ pathLength: 1 }}
transition={{ duration: 1.4, delay: 0.6, ease: 'easeInOut' }}
/>
<motion.circle
cx={W}
cy={H - (POINTS[POINTS.length - 1] / 100) * H}
r="4.5"
fill="var(--accent)"
initial={{ opacity: 0, scale: 0 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ duration: 0.4, delay: 2 }}
/>
<motion.circle
cx={W}
cy={H - (POINTS[POINTS.length - 1] / 100) * H}
r="9"
fill="none"
stroke="var(--accent)"
strokeWidth="2"
initial={{ opacity: 0.6, scale: 0.6 }}
animate={{ opacity: 0, scale: 1.8 }}
transition={{ duration: 1.6, delay: 2, repeat: Infinity, ease: 'easeOut' }}
/>
</svg>
<div className="grid grid-cols-3 gap-2 mt-2">
{badges.map((b, i) => {
const Icon = b.icon;
return (
<motion.div
key={b.label}
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5, delay: 1.4 + i * 0.15 }}
className="rounded-lg p-2.5 text-center"
style={{ background: 'var(--overlay-04)', border: '1px solid var(--overlay-06)' }}
>
<Icon size={14} className="mx-auto mb-1" style={{ color: b.color }} />
<p className="text-[13px] font-semibold" style={{ color: 'var(--foreground)' }}>{b.value}</p>
<p className="text-[9px] uppercase tracking-wider" style={{ color: 'var(--muted-fg)' }}>{b.label}</p>
</motion.div>
);
})}
</div>
</motion.div>
);
}

View file

@ -0,0 +1,19 @@
import React from 'react';
import Link from 'next/link';
export function PublicFooter() {
return (
<footer style={{ borderTop: '1px solid var(--hairline)' }}>
<div className="max-w-6xl mx-auto px-6 py-10 flex flex-col md:flex-row items-center justify-between gap-4">
<p className="text-xs" style={{ color: 'var(--muted-fg)' }}>
© {new Date().getFullYear()} MetaAds Pro. Powered by Meta MCP + OpenAI.
</p>
<nav className="flex items-center gap-6 text-xs font-medium" style={{ color: 'var(--muted-fg)' }}>
<Link href="/privacy" className="hover:text-[var(--accent)] transition-colors">Política de Privacidade</Link>
<Link href="/terms" className="hover:text-[var(--accent)] transition-colors">Termos de Uso</Link>
<Link href="/data-deletion" className="hover:text-[var(--accent)] transition-colors">Exclusão de Dados</Link>
</nav>
</div>
</footer>
);
}

View file

@ -0,0 +1,35 @@
'use client';
import React from 'react';
import Link from 'next/link';
import { Zap } from 'lucide-react';
import { ThemeToggle } from '@/components/theme/ThemeToggle';
export function PublicHeader() {
return (
<header className="sticky top-0 z-40 backdrop-blur-xl" style={{ background: 'var(--glass-bg)', borderBottom: '1px solid var(--hairline)' }}>
<div className="max-w-6xl mx-auto px-6 py-4 flex items-center justify-between">
<Link href="/" className="flex items-center gap-2.5">
<div className="w-9 h-9 rounded-xl flex items-center justify-center" style={{ background: 'linear-gradient(135deg, #6366f1, #8b5cf6)' }}>
<Zap size={18} className="text-white" />
</div>
<div>
<h1 className="text-base font-bold leading-tight" style={{ color: 'var(--foreground)' }}>MetaAds</h1>
<p className="text-[10px] uppercase tracking-widest font-semibold" style={{ color: 'var(--accent)' }}>PRO</p>
</div>
</Link>
<div className="flex items-center gap-3">
<ThemeToggle />
<Link
href="/login"
className="px-4 py-2 rounded-xl text-sm font-semibold text-white transition-all hover:brightness-110 hover:scale-[1.02] active:scale-[0.98]"
style={{ background: 'linear-gradient(135deg, #6366f1, #8b5cf6)', boxShadow: '0 4px 20px rgba(99,102,241,0.3)' }}
>
Entrar
</Link>
</div>
</div>
</header>
);
}

View file

@ -0,0 +1,28 @@
'use client';
import { motion } from 'framer-motion';
import { ReactNode } from 'react';
export function ScrollReveal({
children,
delay = 0,
y = 28,
className = '',
}: {
children: ReactNode;
delay?: number;
y?: number;
className?: string;
}) {
return (
<motion.div
initial={{ opacity: 0, y }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: '-60px' }}
transition={{ duration: 0.6, delay, ease: 'easeOut' }}
className={className}
>
{children}
</motion.div>
);
}

View file

@ -0,0 +1,30 @@
'use client';
import { AnimatedCounter } from './AnimatedCounter';
import { ScrollReveal } from './ScrollReveal';
const stats = [
{ value: 4.8, decimals: 1, suffix: 'x', label: 'ROAS médio reportado pelos usuários' },
{ value: 90, suffix: '%', label: 'menos tempo configurando campanhas' },
{ value: 24, suffix: '/7', label: 'monitoramento e sugestões da IA' },
{ value: 3, suffix: ' min', label: 'do link colado à campanha publicada' },
];
export function StatsStrip() {
return (
<section className="max-w-5xl mx-auto px-6 py-4">
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 md:gap-6">
{stats.map((stat, i) => (
<ScrollReveal key={stat.label} delay={i * 0.1} y={20}>
<div className="glass-card p-5 text-center h-full">
<p className="text-2xl md:text-3xl font-bold gradient-text mb-1">
<AnimatedCounter value={stat.value} suffix={stat.suffix} decimals={stat.decimals ?? 0} />
</p>
<p className="text-xs leading-snug" style={{ color: 'var(--muted-fg)' }}>{stat.label}</p>
</div>
</ScrollReveal>
))}
</div>
</section>
);
}

View file

@ -0,0 +1,56 @@
'use client';
import React, { createContext, useContext, useEffect, useState } from 'react';
type Theme = 'dark' | 'light';
interface ThemeContextValue {
theme: Theme;
toggleTheme: () => void;
}
const ThemeContext = createContext<ThemeContextValue | null>(null);
const STORAGE_KEY = 'metaads-theme';
// Inline script injected before paint so the correct theme is applied
// immediately on load — avoids a flash of the wrong theme.
export const themeInitScript = `
(function() {
try {
var stored = localStorage.getItem('${STORAGE_KEY}');
var theme = stored === 'light' || stored === 'dark' ? stored : 'dark';
document.documentElement.setAttribute('data-theme', theme);
} catch (e) {
document.documentElement.setAttribute('data-theme', 'dark');
}
})();
`;
export function ThemeProvider({ children }: { children: React.ReactNode }) {
const [theme, setTheme] = useState<Theme>('dark');
useEffect(() => {
const stored = localStorage.getItem(STORAGE_KEY);
if (stored === 'light' || stored === 'dark') {
setTheme(stored);
} else {
setTheme(document.documentElement.getAttribute('data-theme') === 'light' ? 'light' : 'dark');
}
}, []);
useEffect(() => {
document.documentElement.setAttribute('data-theme', theme);
localStorage.setItem(STORAGE_KEY, theme);
}, [theme]);
const toggleTheme = () => setTheme((prev) => (prev === 'dark' ? 'light' : 'dark'));
return <ThemeContext.Provider value={{ theme, toggleTheme }}>{children}</ThemeContext.Provider>;
}
export function useTheme(): ThemeContextValue {
const ctx = useContext(ThemeContext);
if (!ctx) throw new Error('useTheme must be used within a ThemeProvider');
return ctx;
}

View file

@ -0,0 +1,27 @@
'use client';
import React from 'react';
import { Sun, Moon } from 'lucide-react';
import { useTheme } from './ThemeProvider';
interface ThemeToggleProps {
className?: string;
}
export function ThemeToggle({ className = '' }: ThemeToggleProps) {
const { theme, toggleTheme } = useTheme();
const isDark = theme === 'dark';
return (
<button
type="button"
onClick={toggleTheme}
title={isDark ? 'Mudar para modo claro' : 'Mudar para modo escuro'}
aria-label={isDark ? 'Mudar para modo claro' : 'Mudar para modo escuro'}
className={`relative flex items-center justify-center w-9 h-9 rounded-xl transition-all duration-200 hover:scale-105 active:scale-95 ${className}`}
style={{ background: 'var(--overlay-04)', border: '1px solid var(--overlay-08)', color: 'var(--muted-fg)' }}
>
{isDark ? <Sun size={16} /> : <Moon size={16} />}
</button>
);
}

View file

@ -0,0 +1,60 @@
'use client';
import React, { useState } from 'react';
import { AlertTriangle, ChevronDown, ChevronUp } from 'lucide-react';
import type { FriendlyError } from '@/lib/errors';
interface ErrorBlockProps {
error: FriendlyError | string;
className?: string;
}
export function ErrorBlock({ error, className = '' }: ErrorBlockProps) {
const [showTechnical, setShowTechnical] = useState(false);
const friendly: FriendlyError = typeof error === 'string' ? { title: 'Algo deu errado', message: error } : error;
return (
<div
className={`px-5 py-4 rounded-xl animate-fade-in ${className}`}
style={{ background: 'rgba(239,68,68,0.08)', border: '1px solid rgba(239,68,68,0.15)' }}
>
<div className="flex items-start gap-3">
<div className="mt-0.5 shrink-0" style={{ color: '#f87171' }}>
<AlertTriangle size={18} />
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-semibold" style={{ color: '#f87171' }}>{friendly.title}</p>
<p className="text-sm mt-0.5 whitespace-pre-wrap" style={{ color: 'rgba(248,113,113,0.85)' }}>{friendly.message}</p>
{friendly.hint && (
<p className="text-xs mt-2" style={{ color: 'var(--muted-fg)' }}>
<span className="font-medium" style={{ color: '#f87171' }}>O que fazer: </span>
{friendly.hint}
</p>
)}
{friendly.technical && (
<div className="mt-2">
<button
type="button"
onClick={() => setShowTechnical((v) => !v)}
className="flex items-center gap-1 text-xs font-medium transition-colors"
style={{ color: 'var(--muted-fg)' }}
>
{showTechnical ? <ChevronUp size={12} /> : <ChevronDown size={12} />}
{showTechnical ? 'Ocultar detalhes técnicos' : 'Ver detalhes técnicos'}
</button>
{showTechnical && (
<pre
className="mt-2 p-3 rounded-lg text-[11px] overflow-x-auto whitespace-pre-wrap break-all"
style={{ background: 'rgba(0,0,0,0.25)', color: 'var(--muted-fg)', maxHeight: '240px', overflowY: 'auto' }}
>
{friendly.technical}
</pre>
)}
</div>
)}
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,34 @@
'use client';
import React from 'react';
interface ModalProps {
isOpen: boolean;
onClose: () => void;
title: string;
children: React.ReactNode;
}
export function Modal({ isOpen, onClose, title, children }: ModalProps) {
if (!isOpen) return null;
return (
<div className="modal-overlay" onClick={onClose}>
<div className="modal-content" onClick={(e) => e.stopPropagation()}>
<div className="flex items-center justify-between mb-6">
<h3 className="text-lg font-semibold text-white">{title}</h3>
<button
onClick={onClose}
className="text-[var(--muted-fg)] hover:text-white transition-colors p-1 rounded-lg hover:bg-white/5"
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
</div>
{children}
</div>
</div>
);
}

View file

@ -0,0 +1,44 @@
'use client';
import React from 'react';
interface SpinnerProps {
size?: 'sm' | 'md' | 'lg';
className?: string;
}
export function Spinner({ size = 'md', className = '' }: SpinnerProps) {
const sizeMap = {
sm: 'w-4 h-4',
md: 'w-6 h-6',
lg: 'w-10 h-10',
};
return (
<div className={`${sizeMap[size]} ${className}`}>
<svg
className="animate-spin-slow"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<circle
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeOpacity="0.2"
strokeWidth="3"
fill="none"
/>
<path
d="M12 2a10 10 0 0 1 10 10"
stroke="currentColor"
strokeWidth="3"
strokeLinecap="round"
fill="none"
/>
</svg>
</div>
);
}

208
src/config/skins/index.ts Normal file
View file

@ -0,0 +1,208 @@
export type FieldType = 'text' | 'textarea' | 'number';
export interface SkinField {
id: string;
label: string;
placeholder: string;
type: FieldType;
required?: boolean;
}
export interface SkinConfig {
id: string;
name: string;
icon: string; // Emoji for simplicity
themeColor: string; // Tailwind class like 'bg-blue-600'
accentColor: string; // Hex color for AI Prompt context if needed
// Custom form fields that replace the generic ones
fields: SkinField[];
// AI Injection Context
ai_guidelines: {
copy_rules: string;
image_style: string;
};
// Meta Ads Defaults
meta_defaults: {
objective: string;
targeting_template: string;
bid_strategy: string;
};
}
export const SYSTEM_SKINS: SkinConfig[] = [
{
id: 'generic',
name: 'Padrão / Genérico',
icon: '🚀',
themeColor: 'bg-white/10 border-white/20',
accentColor: '#ffffff',
fields: [
{ id: 'product', label: 'O que você está vendendo?', placeholder: 'Ex: Mentoria de Vendas, Tênis Esportivo...', type: 'text', required: true },
{ id: 'audience', label: 'Quem é o seu público?', placeholder: 'Ex: Homens de 25 a 40 anos que gostam de esportes...', type: 'textarea' },
],
ai_guidelines: {
copy_rules: 'Use uma linguagem persuasiva e direta.',
image_style: 'Minimalista, alta conversão, foco no produto',
},
meta_defaults: {
objective: 'OUTCOME_TRAFFIC',
targeting_template: 'broad_brazil',
bid_strategy: 'VOLUME',
}
},
{
id: 'real_estate',
name: 'Corretor / Imobiliária',
icon: '🏢',
themeColor: 'bg-blue-900/40 border-blue-500/50',
accentColor: '#3b82f6',
fields: [
{ id: 'property_type', label: 'Tipo de Imóvel', placeholder: 'Ex: Apartamento 3 suítes, Casa em Condomínio...', type: 'text', required: true },
{ id: 'location', label: 'Localização (Bairro/Cidade)', placeholder: 'Ex: Vila Nova Conceição, SP', type: 'text', required: true },
{ id: 'price', label: 'Valor (Aproximado)', placeholder: 'Ex: A partir de R$ 850.000', type: 'text' },
{ id: 'differentials', label: 'Diferenciais do Imóvel', placeholder: 'Ex: Varanda gourmet, 2 vagas, lazer completo...', type: 'textarea' }
],
ai_guidelines: {
copy_rules: 'Você é um corretor de imóveis de alto padrão. Crie anúncios sofisticados, focados no sonho da casa própria, localização privilegiada e valorização do metro quadrado. Use jargões do mercado imobiliário com elegância.',
image_style: 'Fotografia de arquitetura realista, iluminação natural, interior luxuoso de imóvel, aconchegante',
},
meta_defaults: {
objective: 'OUTCOME_LEADS', // Imóveis costumam captar leads
targeting_template: 'broad_brazil', // We could create a specific one later
bid_strategy: 'VOLUME',
}
},
{
id: 'dentist',
name: 'Odontologia / Estética',
icon: '🦷',
themeColor: 'bg-teal-900/40 border-teal-500/50',
accentColor: '#14b8a6',
fields: [
{ id: 'treatment', label: 'Qual tratamento deseja anunciar?', placeholder: 'Ex: Lentes de Contato Dental, Implantes, Botox...', type: 'text', required: true },
{ id: 'clinic_name', label: 'Nome da sua Clínica', placeholder: 'Ex: OdontoPremium', type: 'text' },
{ id: 'pain_point', label: 'Qual a principal dor do paciente?', placeholder: 'Ex: Vergonha de sorrir, falta de dentes...', type: 'textarea' },
],
ai_guidelines: {
copy_rules: 'Você é um especialista em marketing médico e odontológico. Foque em auto-estima, confiança, segurança do procedimento e sorriso perfeito. Evite prometer resultados milagrosos (política do Facebook), foque no bem-estar.',
image_style: 'Pessoa sorrindo com dentes perfeitos, ambiente clínico moderno e limpo, tons de azul e branco, iluminação suave',
},
meta_defaults: {
objective: 'OUTCOME_TRAFFIC',
targeting_template: 'broad_brazil',
bid_strategy: 'VOLUME',
}
},
{
id: 'ecommerce',
name: 'E-commerce / Dropshipping',
icon: '🛍️',
themeColor: 'bg-orange-900/40 border-orange-500/50',
accentColor: '#f97316',
fields: [
{ id: 'product_name', label: 'Nome do Produto', placeholder: 'Ex: Smartwatch Ultra Pro...', type: 'text', required: true },
{ id: 'offer', label: 'Oferta / Preço', placeholder: 'Ex: 50% de Desconto + Frete Grátis', type: 'text', required: true },
{ id: 'benefits', label: 'Principais Benefícios', placeholder: 'Ex: Bateria dura 7 dias, à prova d\'água...', type: 'textarea' },
],
ai_guidelines: {
copy_rules: 'Use escassez, urgência e gatilhos mentais fortes. Foque no problema que o produto resolve e na oferta irresistível.',
image_style: 'Produto em destaque, fundo limpo, cores vibrantes, design focado em conversão e oferta',
},
meta_defaults: {
objective: 'OUTCOME_SALES',
targeting_template: 'broad_brazil',
bid_strategy: 'VOLUME',
}
},
{
id: 'infoproduct',
name: 'Infoprodutos / Lançamentos',
icon: '📚',
themeColor: 'bg-purple-900/40 border-purple-500/50',
accentColor: '#a855f7',
fields: [
{ id: 'course_name', label: 'Nome do Curso/Mentoria', placeholder: 'Ex: Método Venda Todo Dia...', type: 'text', required: true },
{ id: 'expert_name', label: 'Nome do Especialista', placeholder: 'Ex: João Silva', type: 'text' },
{ id: 'promise', label: 'Promessa Principal', placeholder: 'Ex: Aprenda a faturar 10k em 30 dias...', type: 'textarea', required: true },
],
ai_guidelines: {
copy_rules: 'Foque na transformação e na autoridade do especialista. Use storytelling curto e convite claro para a ação (ex: Cadastre-se, Assista a aula).',
image_style: 'Especialista apontando ou sorrindo, fundo de alta qualidade, elementos que remetem a sucesso e conhecimento',
},
meta_defaults: {
objective: 'OUTCOME_LEADS',
targeting_template: 'broad_brazil',
bid_strategy: 'VOLUME',
}
},
{
id: 'delivery',
name: 'Delivery / Restaurante',
icon: '🍔',
themeColor: 'bg-red-900/40 border-red-500/50',
accentColor: '#ef4444',
fields: [
{ id: 'food_type', label: 'Tipo de Comida', placeholder: 'Ex: Hambúrguer Artesanal, Pizza, Sushi...', type: 'text', required: true },
{ id: 'promo', label: 'Promoção do Dia', placeholder: 'Ex: Compre 1 Leve 2, Frete Grátis na primeira compra...', type: 'text' },
{ id: 'location', label: 'Bairros de Entrega', placeholder: 'Ex: Centro, Zona Sul...', type: 'textarea' },
],
ai_guidelines: {
copy_rules: 'Desperte desejo imediato. Use linguagem apetitosa, foque na rapidez da entrega e promoções imperdíveis. Textos curtos e diretos.',
image_style: 'Comida suculenta e em close, cores quentes (vermelho, laranja, amarelo), vapor saindo, muito apetitoso',
},
meta_defaults: {
objective: 'OUTCOME_ENGAGEMENT',
targeting_template: 'broad_brazil',
bid_strategy: 'VOLUME',
}
},
{
id: 'local_service',
name: 'Serviços Locais',
icon: '🔧',
themeColor: 'bg-yellow-900/40 border-yellow-500/50',
accentColor: '#eab308',
fields: [
{ id: 'service', label: 'Serviço Prestado', placeholder: 'Ex: Encanador, Eletricista, Limpeza de Sofá...', type: 'text', required: true },
{ id: 'urgency', label: 'Resolve qual urgência?', placeholder: 'Ex: Vazamentos, Curto-circuito, Sujeira pesada...', type: 'text' },
{ id: 'city', label: 'Cidade/Região de Atendimento', placeholder: 'Ex: Grande São Paulo', type: 'text', required: true },
],
ai_guidelines: {
copy_rules: 'Foque na solução rápida de problemas. Passe confiança, segurança e experiência. Use chamadas claras para solicitar um orçamento no WhatsApp.',
image_style: 'Profissional sorrindo com uniforme, ferramentas de trabalho, ambiente limpo e seguro, transmitindo confiança',
},
meta_defaults: {
objective: 'OUTCOME_ENGAGEMENT', // Usually WhatsApp messages
targeting_template: 'broad_brazil',
bid_strategy: 'VOLUME',
}
},
{
id: 'lawyer',
name: 'Advogados / Escritórios',
icon: '⚖️',
themeColor: 'bg-slate-900/40 border-slate-500/50',
accentColor: '#64748b',
fields: [
{ id: 'specialty', label: 'Especialidade', placeholder: 'Ex: Direito Trabalhista, Previdenciário, Família...', type: 'text', required: true },
{ id: 'target_client', label: 'Cliente Ideal', placeholder: 'Ex: Trabalhador sem registro, Aposentados...', type: 'text' },
{ id: 'action', label: 'Qual direito proteger/buscar?', placeholder: 'Ex: Revisão de aposentadoria, acerto rescisório...', type: 'textarea' },
],
ai_guidelines: {
copy_rules: 'Siga rigorosamente o código de ética da OAB: seja informativo, não mercantilista. Evite prometer ganhos de causa. Foco em orientação jurídica e defesa de direitos.',
image_style: 'Ambiente de escritório sério, livros de direito, balança da justiça discreta, cores sóbrias (azul marinho, cinza, preto), profissionalismo',
},
meta_defaults: {
objective: 'OUTCOME_LEADS',
targeting_template: 'broad_brazil',
bid_strategy: 'VOLUME',
}
}
];
export function getSkin(id: string): SkinConfig {
return SYSTEM_SKINS.find(s => s.id === id) || SYSTEM_SKINS[0];
}

121
src/lib/ai/analyzer.ts Normal file
View file

@ -0,0 +1,121 @@
// ============================================
// AI Layer — Project Analyzer (link → niche, fields, targeting)
// ============================================
import { getAIClient } from './client';
import { scrapeWebsiteContext } from './scraper';
import { SYSTEM_SKINS } from '@/config/skins';
const ANALYZER_MODEL = 'gpt-5.4-mini';
const VALID_OBJECTIVES = [
'OUTCOME_TRAFFIC',
'OUTCOME_LEADS',
'OUTCOME_AWARENESS',
'OUTCOME_ENGAGEMENT',
'OUTCOME_SALES',
];
export interface ProjectAnalysis {
skin_id: string;
campaign_name: string;
fields: Record<string, string>;
audience: string;
objective: string;
tone: string;
image_style: string;
summary: string;
}
export async function analyzeProject(input: { link?: string; description?: string }): Promise<ProjectAnalysis> {
const link = input.link?.trim();
const description = input.description?.trim();
if (!link && !description) {
throw new Error('Informe o link do projeto ou descreva seu produto/negócio para a IA analisar.');
}
let sourceText = description || '';
let sourceLabel = 'DESCRIÇÃO INFORMADA PELO USUÁRIO';
if (link) {
const scrapedText = await scrapeWebsiteContext(link, 4000);
if (!scrapedText && !description) {
throw new Error('Não foi possível extrair conteúdo do link informado. Verifique se a URL está correta e acessível, ou descreva seu produto manualmente.');
}
if (scrapedText) {
sourceText = description ? `${description}\n\n---\n\n${scrapedText}` : scrapedText;
sourceLabel = description ? 'DESCRIÇÃO DO USUÁRIO + CONTEÚDO EXTRAÍDO DO SITE' : 'CONTEÚDO EXTRAÍDO DO SITE';
}
}
const skinsCatalog = SYSTEM_SKINS.map((skin) => ({
id: skin.id,
name: skin.name,
fields: skin.fields.map((f) => ({ id: f.id, label: f.label, type: f.type, required: !!f.required })),
default_objective: skin.meta_defaults.objective,
default_image_style: skin.ai_guidelines.image_style,
}));
const openai = getAIClient();
const prompt = `Você é um estrategista sênior de tráfego pago (Meta Ads) que recebe informações sobre um site/produto/serviço e precisa montar a base de uma campanha profissional e bem direcionada, sem que o usuário precise preencher nada manualmente.
${sourceLabel}:
---
${sourceText}
---
${link ? `LINK ORIGINAL: ${link}` : 'Nenhum link foi informado — baseie-se apenas na descrição acima.'}
NICHOS (SKINS) DISPONÍVEIS NO SISTEMA escolha o que melhor representa o negócio (use "generic" se nenhum se encaixar bem):
${JSON.stringify(skinsCatalog, null, 2)}
OBJETIVOS VÁLIDOS NO META ADS: ${VALID_OBJECTIVES.join(', ')}
SUA TAREFA:
1. Identifique o "skin_id" mais adequado dentre os disponíveis.
2. Para CADA campo definido nesse skin (array "fields"), gere um valor coerente e específico baseado no conteúdo real do site (nunca deixe vazio; se a informação não existir explicitamente, infira de forma plausível a partir do contexto).
3. Sugira um "campaign_name" curto e profissional (ex: "Nome da Marca — Campanha [mês/ano ou ângulo]").
4. Descreva o público-alvo ideal em uma frase curta e direcionada (máx. 12 palavras).
5. Escolha o "objective" mais estratégico dentre os válidos para esse tipo de negócio.
6. Defina o "tone" (tom de voz ideal para a copy, ex: "consultivo e confiável", "urgente e persuasivo").
7. Refine o "image_style" do skin escolhido com detalhes específicos do negócio analisado (cores, ambientação, elementos visuais que conversam com a marca).
8. Escreva um "summary" de 1-2 frases explicando por que você direcionou a campanha dessa forma.
Retorne APENAS um JSON com esta estrutura exata:
{
"skin_id": "...",
"campaign_name": "...",
"fields": { "<id_do_campo>": "...", ... },
"audience": "...",
"objective": "...",
"tone": "...",
"image_style": "...",
"summary": "..."
}`;
const completion = await openai.chat.completions.create({
model: ANALYZER_MODEL,
messages: [{ role: 'user', content: prompt }],
response_format: { type: 'json_object' },
});
const content = completion.choices[0]?.message?.content;
if (!content) throw new Error('A IA não retornou nenhuma análise.');
const parsed = JSON.parse(content);
const skinId = SYSTEM_SKINS.some((s) => s.id === parsed.skin_id) ? parsed.skin_id : 'generic';
const objective = VALID_OBJECTIVES.includes(parsed.objective) ? parsed.objective : 'OUTCOME_TRAFFIC';
return {
skin_id: skinId,
campaign_name: parsed.campaign_name || '',
fields: parsed.fields || {},
audience: parsed.audience || '',
objective,
tone: parsed.tone || '',
image_style: parsed.image_style || '',
summary: parsed.summary || '',
};
}

26
src/lib/ai/client.ts Normal file
View file

@ -0,0 +1,26 @@
// ============================================
// AI Layer — OpenAI Client Init
// ============================================
import OpenAI from 'openai';
import { getSettings } from '@/lib/settings';
let client: OpenAI | null = null;
export function getAIClient(): OpenAI {
if (!client) {
const settings = getSettings();
const apiKey = settings.openaiApiKey || process.env.OPENAI_API_KEY;
const baseURL = process.env.OPENAI_BASE_URL;
if (!apiKey || apiKey === 'your_openai_api_key') {
throw new Error('OPENAI_API_KEY não configurada. Acesse Configurações para adicionar sua chave.');
}
const config: any = { apiKey };
if (baseURL) config.baseURL = baseURL;
client = new OpenAI(config);
}
return client;
}

123
src/lib/ai/copywriter.ts Normal file
View file

@ -0,0 +1,123 @@
// ============================================
// AI Layer — Ad Copywriter (OpenAI gpt-5.4-mini)
// ============================================
import { getAIClient } from './client';
import { scrapeWebsiteContext } from './scraper';
const COPY_MODEL = 'gpt-5.4-mini';
export interface AdCopyVariation {
headline: string;
primary_text: string;
description: string;
cta: string;
}
export interface CopyGenerationInput {
context: string;
copy_rules?: string;
objective: string;
category?: string;
link?: string;
tone?: string;
language?: string;
count?: number;
}
export async function generateAdCopy(input: CopyGenerationInput): Promise<AdCopyVariation[]> {
const openai = getAIClient();
const count = input.count || 3;
const tone = input.tone || 'persuasivo e profissional';
const language = input.language || 'pt-BR';
const categoryContext = input.category ? `Nicho/Categoria do Produto: ${input.category}\n` : '';
const rulesContext = input.copy_rules ? `Regras do Nicho: ${input.copy_rules}\n` : '';
let websiteContext = '';
if (input.link) {
const scrapedText = await scrapeWebsiteContext(input.link);
if (scrapedText) {
websiteContext = `\nContexto extraído do site do cliente (use essas informações reais para criar a copy):\n---\n${scrapedText}\n---\n`;
}
}
const prompt = `Você é um copywriter especialista em Facebook Ads com anos de experiência em conversão.
Você gera textos otimizados para alta performance em anúncios do Meta (Facebook/Instagram).
REGRAS OBRIGATÓRIAS:
- Headline: máximo 40 caracteres, impactante e direto
- Primary Text: máximo 125 caracteres, gere curiosidade e urgência
- Description: máximo 30 caracteres, complemento da headline
- CTA: use um dos CTAs oficiais do Meta (LEARN_MORE, SHOP_NOW, SIGN_UP, SUBSCRIBE, CONTACT_US, GET_OFFER, BOOK_NOW)
- Idioma: ${language}
- Tom: ${tone}
- Foque em benefícios, não features
- Use gatilhos mentais (escassez, prova social, autoridade)
- Evite clickbait ou promessas impossíveis
Gere ${count} variações de copy para um anúncio de Facebook Ads baseado no contexto abaixo:
${input.context}
Objetivo da Campanha: ${input.objective}
${categoryContext}${rulesContext}${websiteContext}
Retorne um JSON com a seguinte estrutura:
{
"variations": [
{
"headline": "...",
"primary_text": "...",
"description": "...",
"cta": "..."
}
]
}`;
const completion = await openai.chat.completions.create({
model: COPY_MODEL,
messages: [{ role: 'user', content: prompt }],
response_format: { type: 'json_object' },
});
const content = completion.choices[0]?.message?.content;
if (!content) throw new Error('Empty response from OpenAI');
const parsed = JSON.parse(content);
return parsed.variations as AdCopyVariation[];
}
export async function generateAudienceSuggestion(context: string, link: string, category: string): Promise<string> {
const openai = getAIClient();
let websiteContext = '';
if (link) {
const scrapedText = await scrapeWebsiteContext(link);
if (scrapedText) {
websiteContext = `\nContexto extraído do site do cliente (use essas informações reais para criar o público-alvo):\n---\n${scrapedText}\n---\n`;
}
}
const prompt = `Você é um estrategista de tráfego pago especialista em Facebook Ads.
Baseado nas informações abaixo, defina o público-alvo ideal de forma descritiva e persuasiva.
Seja direto e prático.
Contexto da Campanha:
${context || 'Não informado'}
Site/Link: ${link || 'Não informado'}
Nicho/Categoria: ${category || 'Não informado'}
${websiteContext}
Retorne APENAS uma frase curta (máximo de 10 palavras) descrevendo o público-alvo.
Exemplo 1: "Mães e Pais interessados em festas infantis."
Exemplo 2: "Empreendedores e donos de pequenos negócios."
Exemplo 3: "Jovens adultos interessados em academia e suplementação."`;
const completion = await openai.chat.completions.create({
model: COPY_MODEL,
messages: [{ role: 'user', content: prompt }],
});
const content = completion.choices[0]?.message?.content?.trim();
if (!content) throw new Error('Empty response from OpenAI');
return content.replace(/^["']|["']$/g, ''); // Remove aspas caso a IA retorne com aspas
}

65
src/lib/ai/creative.ts Normal file
View file

@ -0,0 +1,65 @@
// ============================================
// AI Layer — Creative / Image Generator (OpenAI gpt-image-2)
// ============================================
import { getAIClient } from './client';
const IMAGE_MODEL = 'gpt-image-2';
export interface ImageGenerationInput {
context: string;
style: string;
dimensions: '1080x1080' | '1080x1920' | '1200x628';
mood?: string;
}
export interface GeneratedImage {
url: string;
revised_prompt: string;
dimensions: string;
}
const sizeMap: Record<string, '1024x1024' | '1024x1792' | '1792x1024'> = {
'1080x1080': '1024x1024',
'1080x1920': '1024x1792',
'1200x628': '1792x1024',
};
export async function generateAdImage(input: ImageGenerationInput): Promise<GeneratedImage> {
const mood = input.mood || 'moderno, clean, profissional';
const size = sizeMap[input.dimensions] || '1024x1024';
const prompt = `Crie um criativo de anúncio (Facebook/Instagram Ad) altamente persuasivo e visualmente impactante.
O contexto do anúncio e as regras do nicho são os seguintes:
---
${input.context}
---
Estilo Visual Obrigatório: ${input.style}
Mood: ${mood}
Diretrizes Adicionais:
- A imagem deve ser otimizada para anúncios, sem poluição visual.
- Foco em conversão e chamar a atenção nos primeiros segundos.
- Não inclua muito texto na imagem, deixe o foco no visual (a Meta prefere menos texto).
- O visual deve conversar diretamente com o contexto especificado acima.`;
const openai = getAIClient();
const result = await openai.images.generate({
model: IMAGE_MODEL,
prompt,
n: 1,
size,
});
const image = result.data?.[0];
const imageUrl = image?.url || (image?.b64_json ? `data:image/png;base64,${image.b64_json}` : undefined);
if (!imageUrl) throw new Error('A OpenAI não retornou a imagem gerada.');
return {
url: imageUrl,
revised_prompt: image?.revised_prompt || prompt,
dimensions: input.dimensions,
};
}

68
src/lib/ai/scraper.ts Normal file
View file

@ -0,0 +1,68 @@
import * as cheerio from 'cheerio';
/**
* Faz o fetch de uma URL e extrai o contexto principal (título, meta description e parágrafos)
* para fornecer inteligência para a IA de Copywriting e Público-Alvo.
*/
export async function scrapeWebsiteContext(url: string, maxLength: number = 2000): Promise<string> {
if (!url) return '';
// Validação básica de URL para garantir que tem http:// ou https://
let targetUrl = url;
if (!targetUrl.startsWith('http')) {
targetUrl = `https://${targetUrl}`;
}
try {
const response = await fetch(targetUrl, {
headers: {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'
},
// Timeout curto para evitar gargalo na geração se o site for muito lento
signal: AbortSignal.timeout(5000)
});
if (!response.ok) {
console.warn(`[Scraper] Falha ao ler ${targetUrl}: ${response.statusText}`);
return '';
}
const html = await response.text();
const $ = cheerio.load(html);
// Remover tags desnecessárias que poluem o texto
$('script, style, noscript, nav, footer, header, iframe, svg').remove();
const title = $('title').text().trim() || '';
const description = $('meta[name="description"]').attr('content')?.trim() || '';
// Coletar textos focados no conteúdo (Headers e Parágrafos)
const elementsText: string[] = [];
$('h1, h2, h3, p').each((_, element) => {
const text = $(element).text().trim().replace(/\s+/g, ' ');
if (text && text.length > 10) {
elementsText.push(text);
}
});
let context = '';
if (title) context += `Título do Site: ${title}\n`;
if (description) context += `Descrição (Meta): ${description}\n`;
if (elementsText.length > 0) {
context += `\nTextos Principais da Página:\n`;
context += elementsText.join('\n');
}
// Limitar o contexto para economizar tokens e focar na primeira dobra/promessa principal
if (context.length > maxLength) {
context = context.substring(0, maxLength) + '...';
}
return context.trim();
} catch (error) {
console.warn(`[Scraper] Erro ao tentar processar a URL ${targetUrl}:`, error);
return ''; // Falha silenciosa, a IA usará apenas o link cru como contexto
}
}

98
src/lib/ai/strategist.ts Normal file
View file

@ -0,0 +1,98 @@
// ============================================
// AI Layer — Performance Strategist
// ============================================
import { getAIClient } from './client';
import type { AccountOverview, CampaignWithInsights } from '@/lib/meta/types';
export interface StrategicAnalysis {
summary: string;
top_performers: string[];
issues: string[];
recommendations: string[];
suggested_actions: SuggestedAction[];
}
export interface SuggestedAction {
campaign_id: string;
campaign_name: string;
action: 'pause' | 'increase_budget' | 'reduce_budget' | 'create_variation';
reason: string;
priority: 'high' | 'medium' | 'low';
}
export async function analyzePerformance(
overview: AccountOverview,
campaigns: CampaignWithInsights[]
): Promise<StrategicAnalysis> {
const client = getAIClient();
const campaignSummary = campaigns.map((c) => {
const leads = c.insights?.actions?.find(
(a) => a.action_type === 'lead' || a.action_type === 'onsite_conversion.lead_grouped'
);
const roas = c.insights?.purchase_roas?.[0];
return {
id: c.campaign.id,
name: c.campaign.name,
status: c.campaign.status,
objective: c.campaign.objective,
daily_budget: `R$${(parseInt(c.campaign.daily_budget || '0', 10) / 100).toFixed(2)}`,
spend: `R$${parseFloat(c.insights?.spend || '0').toFixed(2)}`,
ctr: `${parseFloat(c.insights?.ctr || '0').toFixed(2)}%`,
cpc: `R$${parseFloat(c.insights?.cpc || '0').toFixed(2)}`,
cpm: `R$${parseFloat(c.insights?.cpm || '0').toFixed(2)}`,
leads: leads ? parseInt(leads.value, 10) : 0,
roas: roas ? parseFloat(roas.value).toFixed(2) : '0',
};
});
const response = await client.chat.completions.create({
model: 'gpt-4o',
temperature: 0.4,
response_format: { type: 'json_object' },
messages: [
{
role: 'system',
content: `Você é um estrategista de mídia paga expert em Facebook Ads.
Analise os dados de performance e forneça insights acionáveis.
Retorne um JSON com:
- "summary": resumo geral da performance em 2-3 frases (pt-BR)
- "top_performers": array de strings com os nomes das melhores campanhas e por quê
- "issues": array de strings com problemas identificados
- "recommendations": array de strings com recomendações estratégicas
- "suggested_actions": array de objetos com:
- "campaign_id": ID da campanha
- "campaign_name": nome
- "action": "pause" | "increase_budget" | "reduce_budget" | "create_variation"
- "reason": motivo da sugestão
- "priority": "high" | "medium" | "low"
Seja direto, use dados específicos, e foque em ROI.`,
},
{
role: 'user',
content: `Analise a performance das campanhas:
OVERVIEW DA CONTA:
- Gasto Total: R$${overview.total_spend.toFixed(2)}
- Total Leads: ${overview.total_leads}
- ROAS Médio: ${overview.total_roas.toFixed(2)}x
- CTR Médio: ${overview.avg_ctr.toFixed(2)}%
- CPC Médio: R$${overview.avg_cpc.toFixed(2)}
- CPM Médio: R$${overview.avg_cpm.toFixed(2)}
- Campanhas Ativas: ${overview.campaigns_active}
- Campanhas Pausadas: ${overview.campaigns_paused}
CAMPANHAS DETALHADAS:
${JSON.stringify(campaignSummary, null, 2)}`,
},
],
});
const content = response.choices[0]?.message?.content;
if (!content) throw new Error('Empty response from AI');
return JSON.parse(content) as StrategicAnalysis;
}

50
src/lib/auth/session.ts Normal file
View file

@ -0,0 +1,50 @@
// ============================================
// Session Helper — Cookie-based token storage
// ============================================
import { cookies } from 'next/headers';
const TOKEN_COOKIE = 'meta_access_token';
const USER_COOKIE = 'meta_user';
export async function getAccessToken(): Promise<string | null> {
const cookieStore = await cookies();
return cookieStore.get(TOKEN_COOKIE)?.value || null;
}
export async function getUserInfo(): Promise<{ name: string; picture: string } | null> {
const cookieStore = await cookies();
const raw = cookieStore.get(USER_COOKIE)?.value;
if (!raw) return null;
try {
return JSON.parse(raw);
} catch {
return null;
}
}
export function setSessionCookies(
cookieStore: Awaited<ReturnType<typeof cookies>>,
accessToken: string,
user: { name: string; picture: string },
maxAgeSeconds: number = 60 * 60 * 24 * 30 // 30 days
) {
const opts = {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax' as const,
path: '/',
maxAge: maxAgeSeconds,
};
cookieStore.set(TOKEN_COOKIE, accessToken, opts);
cookieStore.set(USER_COOKIE, JSON.stringify(user), {
...opts,
httpOnly: false, // allow client-side reading for UI
});
}
export function clearSessionCookies(cookieStore: Awaited<ReturnType<typeof cookies>>) {
cookieStore.delete(TOKEN_COOKIE);
cookieStore.delete(USER_COOKIE);
}

254
src/lib/engine/monitor.ts Normal file
View file

@ -0,0 +1,254 @@
// ============================================
// Automation Engine — Campaign Monitor (MCP)
// ============================================
import type { Client } from '@/lib/mcp/client';
import { listCampaigns, getCampaignInsights, pauseCampaign, updateCampaignBudget } from '@/lib/mcp/tools';
import type { AutomationRule, RuleEvaluation, ActivityLog } from './rules';
// ---- Extract metric value from MCP insights ----
function getMetricValue(insights: Record<string, unknown> | null, metric: string): number {
if (!insights) return 0;
switch (metric) {
case 'cpl': {
const spend = parseFloat((insights.spend as string) || '0');
const leads = parseInt((insights.leads as string) || '0', 10);
if (leads === 0) return 0;
return spend / leads;
}
case 'cpc':
return parseFloat((insights.cpc as string) || '0');
case 'ctr':
return parseFloat((insights.ctr as string) || '0');
case 'roas':
return parseFloat((insights.roas as string) || '0');
case 'cpm':
return parseFloat((insights.cpm as string) || '0');
case 'spend':
return parseFloat((insights.spend as string) || '0');
default:
return 0;
}
}
// ---- Check if rule condition is met ----
function evaluateCondition(value: number, operator: string, threshold: number): boolean {
switch (operator) {
case '>': return value > threshold;
case '<': return value < threshold;
case '>=': return value >= threshold;
case '<=': return value <= threshold;
default: return false;
}
}
// ---- Check minimums ----
function meetsMinimums(insights: Record<string, unknown> | null, rule: AutomationRule): boolean {
if (!insights) return false;
const spend = parseFloat((insights.spend as string) || '0');
const impressions = parseInt((insights.impressions as string) || '0', 10);
return spend >= rule.min_spend && impressions >= rule.min_impressions;
}
// ---- Check cooldown ----
function isInCooldown(
campaignId: string,
ruleId: string,
recentLogs: ActivityLog[],
cooldownHours: number
): boolean {
const cutoff = new Date(Date.now() - cooldownHours * 60 * 60 * 1000);
return recentLogs.some(
(log) =>
log.campaign_id === campaignId &&
log.rule_id === ruleId &&
log.status === 'executed' &&
new Date(log.created_at || '') > cutoff
);
}
// ---- Execute action on campaign via MCP ----
async function executeAction(
mcpClient: Client,
campaignId: string,
dailyBudget: number,
insights: Record<string, unknown>,
rule: AutomationRule
): Promise<{ success: boolean; details: string }> {
try {
switch (rule.action) {
case 'pause': {
await pauseCampaign(mcpClient, campaignId);
return {
success: true,
details: `Campanha pausada. ${rule.metric.toUpperCase()} = ${getMetricValue(insights, rule.metric).toFixed(2)} (threshold: ${rule.threshold})`,
};
}
case 'reduce_budget': {
const reduction = rule.action_value || 30;
const newBudget = Math.round(dailyBudget * (1 - reduction / 100));
if (newBudget < 100) return { success: false, details: 'Budget já no mínimo (R$1.00)' };
await updateCampaignBudget(mcpClient, campaignId, newBudget);
return {
success: true,
details: `Budget reduzido ${reduction}%: R$${(dailyBudget / 100).toFixed(2)} → R$${(newBudget / 100).toFixed(2)}`,
};
}
case 'increase_budget': {
const increase = rule.action_value || 20;
const newBgt = Math.round(dailyBudget * (1 + increase / 100));
await updateCampaignBudget(mcpClient, campaignId, newBgt);
return {
success: true,
details: `Budget aumentado ${increase}%: R$${(dailyBudget / 100).toFixed(2)} → R$${(newBgt / 100).toFixed(2)}`,
};
}
case 'notify': {
return {
success: true,
details: `Alerta: ${rule.metric.toUpperCase()} = ${getMetricValue(insights, rule.metric).toFixed(2)} (threshold: ${rule.threshold})`,
};
}
case 'replace_with_ai': {
// 1. Pausar a campanha atual
await pauseCampaign(mcpClient, campaignId);
// 2. Acionar a IA para gerar novos criativos
try {
// Gerar Copy via OpenAI (gpt-5.4-mini)
const { getAIClient } = await import('@/lib/ai/client');
const openai = getAIClient();
const copyPrompt = `A campanha "${insights.campaign_name || campaignId}" performou mal (CPL alto / ROAS baixo).
Você é um expert em Meta Ads. Crie uma nova Headline curta (max 40 chars) e um Primary Text (max 125 chars) muito persuasivo para substituí-la.
Retorne em JSON: {"headline": "...", "primary_text": "..."}`;
const copyResult = await openai.chat.completions.create({
model: 'gpt-5.4-mini',
messages: [{ role: 'user', content: copyPrompt }],
response_format: { type: 'json_object' },
});
const copy = JSON.parse(copyResult.choices[0]?.message?.content || '{}');
// Gerar Imagem via OpenAI (gpt-image-2)
const imageRes = await openai.images.generate({
model: 'gpt-image-2',
prompt: `Uma imagem profissional, chamativa e de alta conversão para um anúncio no Facebook/Instagram sobre: ${copy.headline || 'Produto Inovador'}. Estilo moderno, sem texto na imagem.`,
n: 1,
size: '1024x1024'
});
const image = imageRes.data?.[0];
const imageUrl = image?.url || (image?.b64_json ? `data:image/png;base64,${image.b64_json}` : undefined);
if (!imageUrl) throw new Error('Falha ao gerar imagem');
// 3. Montar e Publicar a Nova Campanha via MCP
const { createFullCampaign } = await import('@/lib/mcp/tools');
const newCampaign = await createFullCampaign(mcpClient, {
campaign_name: `[AI Gen] Substituto de ${insights.campaign_name || campaignId}`,
objective: 'OUTCOME_LEADS', // default safe objective
daily_budget: dailyBudget > 0 ? dailyBudget : 1000,
bid_strategy: 'LOWEST_COST_WITH_BID_CAP',
bid_amount: 5000, // Fixed 50 BRL bid cap for safety
targeting: { geo_locations: { countries: ['BR'] } },
page_id: '123456789', // requires a valid page_id in production
image_url: imageUrl,
link: 'https://seusite.com.br',
headline: copy.headline || 'Nova Oferta Irresistível',
primary_text: copy.primary_text || 'Descubra como transformar seus resultados hoje mesmo.',
description: 'Clique e saiba mais',
cta: 'LEARN_MORE',
});
return {
success: true,
details: `Campanha pausada. Nova campanha [AI Gen] criada com sucesso! (ID: ${newCampaign.campaign_id})`,
};
} catch (aiError: any) {
return {
success: false,
details: `Campanha pausada, mas falha ao criar IA: ${aiError.message}`,
};
}
}
default:
return { success: false, details: 'Ação desconhecida' };
}
} catch (error) {
return {
success: false,
details: `Erro: ${error instanceof Error ? error.message : 'Unknown'}`,
};
}
}
// ---- Main evaluation function ----
export async function evaluateCampaigns(
mcpClient: Client,
rules: AutomationRule[],
recentLogs: ActivityLog[] = []
): Promise<RuleEvaluation[]> {
const enabledRules = rules.filter((r) => r.enabled);
if (enabledRules.length === 0) return [];
const campaigns = (await listCampaigns(mcpClient)) as Record<string, unknown>[];
const activeCampaigns = Array.isArray(campaigns)
? campaigns.filter((c) => c.status === 'ACTIVE')
: [];
const evaluations: RuleEvaluation[] = [];
for (const campaign of activeCampaigns) {
const campaignId = campaign.id as string;
const campaignName = campaign.name as string;
const dailyBudget = parseInt((campaign.daily_budget as string) || '0', 10);
let insights: Record<string, unknown> | null = null;
try {
insights = (await getCampaignInsights(mcpClient, campaignId, 'last_7d')) as Record<string, unknown>;
} catch {
continue;
}
for (const rule of enabledRules) {
const metricValue = getMetricValue(insights, rule.metric);
if (!meetsMinimums(insights, rule)) continue;
if (isInCooldown(campaignId, rule.id, recentLogs, rule.cooldown_hours)) {
evaluations.push({
rule,
campaign_id: campaignId,
campaign_name: campaignName,
metric_value: metricValue,
triggered: false,
action_taken: null,
details: `Em cooldown (${rule.cooldown_hours}h)`,
timestamp: new Date().toISOString(),
});
continue;
}
const triggered = evaluateCondition(metricValue, rule.operator, rule.threshold);
if (triggered) {
const result = await executeAction(mcpClient, campaignId, dailyBudget, insights!, rule);
evaluations.push({
rule,
campaign_id: campaignId,
campaign_name: campaignName,
metric_value: metricValue,
triggered: true,
action_taken: result.success ? rule.action : null,
details: result.details,
timestamp: new Date().toISOString(),
});
}
}
}
return evaluations;
}

132
src/lib/engine/rules.ts Normal file
View file

@ -0,0 +1,132 @@
// ============================================
// Automation Engine — Rule Types & Defaults
// ============================================
export type RuleMetric = 'cpl' | 'cpc' | 'ctr' | 'roas' | 'cpm' | 'spend';
export type RuleOperator = '>' | '<' | '>=' | '<=';
export type RuleAction = 'pause' | 'reduce_budget' | 'increase_budget' | 'notify' | 'replace_with_ai';
export interface AutomationRule {
id: string;
name: string;
enabled: boolean;
metric: RuleMetric;
operator: RuleOperator;
threshold: number;
min_spend: number;
min_impressions: number;
action: RuleAction;
action_value?: number;
cooldown_hours: number;
created_at?: string;
updated_at?: string;
}
export interface RuleEvaluation {
rule: AutomationRule;
campaign_id: string;
campaign_name: string;
metric_value: number;
triggered: boolean;
action_taken: RuleAction | null;
details: string;
timestamp: string;
}
export interface ActivityLog {
id?: string;
rule_id: string | null;
rule_name?: string;
campaign_id: string;
campaign_name: string;
action: string;
details: Record<string, unknown>;
status: 'executed' | 'pending' | 'failed' | 'skipped';
created_at?: string;
}
// ---- Metric Labels (PT-BR) ----
export const METRIC_LABELS: Record<RuleMetric, string> = {
cpl: 'Custo por Lead (CPL)',
cpc: 'Custo por Clique (CPC)',
ctr: 'Taxa de Clique (CTR %)',
roas: 'Retorno (ROAS)',
cpm: 'Custo por Mil (CPM)',
spend: 'Gasto Total (Spend)',
};
export const OPERATOR_LABELS: Record<RuleOperator, string> = {
'>': 'Maior que',
'<': 'Menor que',
'>=': 'Maior ou igual a',
'<=': 'Menor ou igual a',
};
export const ACTION_LABELS: Record<RuleAction, string> = {
pause: 'Pausar campanha',
reduce_budget: 'Reduzir orçamento',
increase_budget: 'Aumentar orçamento',
notify: 'Apenas notificar',
replace_with_ai: 'Pausar e Substituir via IA',
};
// ---- Default Rules ----
export const DEFAULT_RULES: Omit<AutomationRule, 'id' | 'created_at' | 'updated_at'>[] = [
{
name: 'CPL Alto — Pausar',
enabled: true,
metric: 'cpl',
operator: '>',
threshold: 50,
min_spend: 30,
min_impressions: 1000,
action: 'pause',
cooldown_hours: 24,
},
{
name: 'ROAS Baixo — Pausar',
enabled: true,
metric: 'roas',
operator: '<',
threshold: 0.5,
min_spend: 50,
min_impressions: 2000,
action: 'pause',
cooldown_hours: 24,
},
{
name: 'CTR Muito Baixo — Pausar',
enabled: true,
metric: 'ctr',
operator: '<',
threshold: 0.5,
min_spend: 20,
min_impressions: 2000,
action: 'pause',
cooldown_hours: 24,
},
{
name: 'CPC Alto — Reduzir Budget 30%',
enabled: false,
metric: 'cpc',
operator: '>',
threshold: 5,
min_spend: 30,
min_impressions: 1000,
action: 'reduce_budget',
action_value: 30,
cooldown_hours: 48,
},
{
name: 'ROAS Excelente — Aumentar Budget 20%',
enabled: false,
metric: 'roas',
operator: '>',
threshold: 3,
min_spend: 100,
min_impressions: 5000,
action: 'increase_budget',
action_value: 20,
cooldown_hours: 48,
},
];

89
src/lib/errors.ts Normal file
View file

@ -0,0 +1,89 @@
// Translates raw API/Meta error payloads into messages a non-technical user can act on,
// while preserving the original technical details for support/debugging.
export interface FriendlyError {
title: string;
message: string;
hint?: string;
technical?: string;
}
const META_SUBCODE_GUIDE: Record<string, { title: string; hint: string }> = {
'1487411': {
title: 'A Meta não aceitou o formato da imagem',
hint: 'Gere novas imagens com a IA (recomendado) ou envie um arquivo JPG/PNG de até 30MB e tente publicar novamente.',
},
'2490433': {
title: 'A Meta recusou esta combinação de criativos',
hint: 'Já ajustamos o formato do anúncio para evitar esse erro — tente publicar novamente. Se persistir, gere novos criativos.',
},
'1885183': {
title: 'Seu app do Facebook está em modo de desenvolvimento',
hint: 'Para publicar anúncios reais, vá em developers.facebook.com → seu app → "Funções/Roles" e adicione sua conta como Administrador ou Tester. Para uso contínuo por outras pessoas, será necessário enviar o app para revisão (App Review) e colocá-lo em modo "Live".',
},
};
const META_CODE_GUIDE: Record<string, { title: string; hint: string }> = {
'100': {
title: 'A Meta recusou os dados enviados',
hint: 'Confira orçamento, link de destino e imagens da campanha. Se os dados estiverem corretos, tente novamente em alguns instantes.',
},
'190': {
title: 'Sua sessão com o Meta expirou',
hint: 'Saia e entre novamente para reconectar sua conta do Meta Ads.',
},
'17': {
title: 'Limite de chamadas à Meta atingido',
hint: 'Aguarde alguns minutos e tente novamente — a Meta limita quantas ações podem ser feitas por hora.',
},
};
function safeStringify(value: unknown): string | undefined {
try {
const str = JSON.stringify(value, null, 2);
return str && str !== '{}' ? str : undefined;
} catch {
return undefined;
}
}
/**
* Parses an error coming from a fetch() response body shaped like
* { success: false, error: string, details?: unknown } the standard
* shape returned by this app's API routes into a friendly message.
*/
export function parseApiError(data: any, fallback = 'Algo deu errado. Tente novamente.'): FriendlyError {
const rawMessage: string = (typeof data?.error === 'string' && data.error) || fallback;
const technical = safeStringify(data?.details ?? data);
// Meta API errors are formatted upstream as:
// "Meta API Error: <user message> (Code: X, Subcode: Y, Type: Z, TraceID: W)"
const metaMatch = rawMessage.match(/^Meta API Error:\s*(.+?)\s*\(Code:\s*(\d+),\s*Subcode:\s*(\w+)/i);
if (metaMatch) {
const [, userMsg, code, subcode] = metaMatch;
const guide = META_SUBCODE_GUIDE[subcode] || META_CODE_GUIDE[code];
return {
title: guide?.title || 'A Meta recusou esta operação',
message: userMsg.trim(),
hint: guide?.hint || 'Verifique os dados da campanha e tente novamente. Copie os detalhes técnicos abaixo se precisar de suporte.',
technical,
};
}
// Network / fetch-level failures
if (/fetch failed|ENOTFOUND|ECONNREFUSED|NetworkError/i.test(rawMessage)) {
return {
title: 'Não conseguimos acessar o link informado',
message: 'O endereço pode estar incorreto, fora do ar, ou bloqueando acessos automáticos.',
hint: 'Confira se digitou o link corretamente (com https://) e se o site está no ar. Você também pode descrever o produto manualmente em vez de usar um link.',
technical,
};
}
return {
title: 'Algo deu errado',
message: rawMessage,
technical,
};
}

357
src/lib/mcp/client.ts Normal file
View file

@ -0,0 +1,357 @@
// ============================================
// Local MCP Client — Wraps Meta Graph API
// ============================================
export interface Client {
accessToken: string;
adAccountId?: string;
close: () => Promise<void>;
}
export async function createMetaMCPClient(accessToken: string): Promise<Client> {
return {
accessToken,
close: async () => {},
};
}
// Helper para requests GET
async function fetchGraphGet(path: string, token: string, params: Record<string, any> = {}) {
const url = new URL(`https://graph.facebook.com/v20.0${path}`);
Object.entries(params).forEach(([key, value]) => url.searchParams.append(key, String(value)));
url.searchParams.append('access_token', token);
const res = await fetch(url.toString(), { method: 'GET' });
const data = await res.json();
if (data.error) throwMetaError(data.error);
return data;
}
// Helper para requests POST com JSON body
async function fetchGraphPostJson(path: string, token: string, body: any = {}) {
const url = new URL(`https://graph.facebook.com/v20.0${path}`);
url.searchParams.append('access_token', token);
const res = await fetch(url.toString(), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
const data = await res.json();
if (data.error) throwMetaError(data.error);
return data;
}
// Helper para requests POST com Form-UrlEncoded (Obrigatório para a maioria dos endpoints de Ads da Meta)
async function fetchGraphPostForm(path: string, token: string, body: Record<string, any> = {}) {
const url = new URL(`https://graph.facebook.com/v20.0${path}`);
const formParams = new URLSearchParams();
formParams.append('access_token', token);
Object.entries(body).forEach(([key, value]) => {
if (value === undefined || value === null) return;
// Meta requires nested objects/arrays to be JSON stringified in form data
if (typeof value === 'object') {
formParams.append(key, JSON.stringify(value));
} else {
formParams.append(key, String(value));
}
});
const res = await fetch(url.toString(), {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: formParams.toString(),
});
const data = await res.json();
if (data.error) throwMetaError(data.error);
return data;
}
function throwMetaError(err: any): never {
const msg = err.error_user_msg || err.message || 'Unknown Meta API Error';
const fullError = `Meta API Error: ${msg} (Code: ${err.code}, Subcode: ${err.error_subcode || 'N/A'}, Type: ${err.type}, TraceID: ${err.fbtrace_id})`;
console.error('[Meta API] Raw error response:', JSON.stringify(err));
throw new Error(fullError);
}
function requireFields(args: Record<string, any>, fields: string[], toolName: string) {
for (const field of fields) {
if (args[field] === undefined || args[field] === null) {
throw new Error(`Missing required field '${field}' for tool '${toolName}'`);
}
}
}
/**
* Mocks the MCP callTool interface but routes to actual Graph API endpoints
*/
export async function callMetaTool(
client: Client,
toolName: string,
args: Record<string, any> = {}
) {
const token = client.accessToken;
// 1. Get Ad Account if not set and not explicitly passed
let accountId = args.ad_account_id || client.adAccountId;
if (!accountId) {
const accounts = await fetchGraphGet('/me/adaccounts', token);
if (!accounts.data || accounts.data.length === 0) {
return { content: [{ type: 'text', text: JSON.stringify([]) }] };
}
accountId = accounts.data[0].id; // Use first ad account as fallback
client.adAccountId = accountId;
}
try {
let resultData: any = {};
switch (toolName) {
case 'list_campaigns': {
const preset = args.date_preset || 'last_30d';
const ins = `insights.date_preset(${preset}){spend,ctr,cpc,cpm,actions,purchase_roas}`;
const fields = `id,name,status,daily_budget,objective,buying_type,${ins},adsets{id,name,status,daily_budget,${ins},ads{id,name,status,${ins}}}`;
resultData = await fetchGraphGet(`/${accountId}/campaigns`, token, { fields });
return { content: [{ type: 'text', text: JSON.stringify(resultData.data || []) }] };
}
case 'list_pages': {
resultData = await fetchGraphGet('/me/accounts', token, {
fields: 'id,name,access_token,instagram_business_account{id,username,profile_picture_url}',
});
return { content: [{ type: 'text', text: JSON.stringify(resultData.data || []) }] };
}
case 'get_insights_for_account':
resultData = await fetchGraphGet(`/${accountId}/insights`, token, {
date_preset: args.date_preset || 'last_30d',
fields: 'impressions,reach,clicks,inline_link_clicks,spend,cpc,cpm,ctr,actions,cost_per_action_type,purchase_roas,website_ctr',
});
return { content: [{ type: 'text', text: JSON.stringify(resultData.data?.[0] || {}) }] };
case 'get_insights_for_campaign':
requireFields(args, ['campaign_id'], toolName);
resultData = await fetchGraphGet(`/${args.campaign_id}/insights`, token, {
date_preset: args.date_preset || 'last_30d',
fields: 'impressions,reach,clicks,inline_link_clicks,spend,cpc,cpm,ctr,actions,cost_per_action_type,purchase_roas,website_ctr',
});
return { content: [{ type: 'text', text: JSON.stringify(resultData.data?.[0] || {}) }] };
case 'get_campaign_details': {
requireFields(args, ['campaign_id'], toolName);
const preset = args.date_preset || 'last_30d';
const ins = `insights.date_preset(${preset}){spend,ctr,cpc,cpm,actions,purchase_roas}`;
const fields = `id,name,status,daily_budget,objective,buying_type,${ins},adsets{id,name,status,daily_budget,${ins},ads{id,name,status,creative{id,name,image_url,thumbnail_url,body,title,object_story_spec,asset_feed_spec},${ins}}}`;
resultData = await fetchGraphGet(`/${args.campaign_id}`, token, { fields });
return { content: [{ type: 'text', text: JSON.stringify(resultData) }] };
}
case 'update_campaign':
requireFields(args, ['campaign_id'], toolName);
const updateCampPayload: any = {};
if (args.status) updateCampPayload.status = args.status;
if (args.daily_budget) updateCampPayload.daily_budget = args.daily_budget; // Only works if CBO
resultData = await fetchGraphPostForm(`/${args.campaign_id}`, token, updateCampPayload);
return { content: [{ type: 'text', text: JSON.stringify(resultData) }] };
case 'update_adset':
requireFields(args, ['adset_id'], toolName);
const updateAdsetPayload: any = {};
if (args.status) updateAdsetPayload.status = args.status;
if (args.daily_budget) updateAdsetPayload.daily_budget = args.daily_budget;
if (args.bid_amount) updateAdsetPayload.bid_amount = args.bid_amount;
resultData = await fetchGraphPostForm(`/${args.adset_id}`, token, updateAdsetPayload);
return { content: [{ type: 'text', text: JSON.stringify(resultData) }] };
case 'update_ad':
requireFields(args, ['ad_id'], toolName);
const updateAdPayload: any = {};
if (args.status) updateAdPayload.status = args.status;
resultData = await fetchGraphPostForm(`/${args.ad_id}`, token, updateAdPayload);
return { content: [{ type: 'text', text: JSON.stringify(resultData) }] };
case 'create_campaign':
requireFields(args, ['name', 'objective'], toolName);
resultData = await fetchGraphPostForm(`/${accountId}/campaigns`, token, {
name: args.name,
objective: args.objective, // e.g. OUTCOME_TRAFFIC, OUTCOME_LEADS, OUTCOME_SALES
status: args.status || 'PAUSED',
special_ad_categories: args.special_ad_categories || [],
buying_type: args.buying_type || 'AUCTION',
});
return { content: [{ type: 'text', text: JSON.stringify(resultData) }] };
case 'create_adset':
requireFields(args, ['name', 'campaign_id', 'daily_budget'], toolName);
if (args.daily_budget < 100) {
throw new Error('daily_budget must be in cents (e.g. R$ 20.00 = 2000). Values < 100 are rejected by safety check.');
}
const adsetPayload: any = {
name: args.name,
campaign_id: args.campaign_id,
daily_budget: args.daily_budget,
billing_event: args.billing_event || 'IMPRESSIONS',
optimization_goal: args.optimization_goal || 'REACH',
targeting: args.targeting || { geo_locations: { countries: ['BR'] } },
status: args.status || 'PAUSED',
};
if (args.promoted_object) {
adsetPayload.promoted_object = args.promoted_object;
}
// Support for Bid Cap (Cost Control)
if (args.bid_strategy) {
adsetPayload.bid_strategy = args.bid_strategy; // e.g. LOWEST_COST_WITH_BID_CAP
}
if (args.bid_amount) {
if (args.bid_amount < 100) throw new Error('bid_amount must be in cents.');
adsetPayload.bid_amount = args.bid_amount;
}
resultData = await fetchGraphPostForm(`/${accountId}/adsets`, token, adsetPayload);
return { content: [{ type: 'text', text: JSON.stringify(resultData) }] };
case 'create_ad_creative':
requireFields(args, ['name', 'page_id', 'link', 'message'], toolName);
if (args.image_hashes && Array.isArray(args.image_hashes) && args.image_hashes.length > 0) {
// Asset Customization / Dynamic Creative
const assetFeedSpec = {
images: args.image_hashes.map((hash: any) => ({ hash })),
bodies: [{ text: args.message }],
titles: [{ text: args.headline || 'Headline' }],
descriptions: [{ text: args.description || '' }],
call_to_action_types: [args.call_to_action_type || 'LEARN_MORE'],
link_urls: [{ website_url: args.link }]
};
// Meta deprecated the bundled `standard_enhancements` opt-out (error subcode 3858504,
// "Defina recursos individuais") — Advantage+ Creative features are now left at their
// platform defaults rather than specified per-feature here.
resultData = await fetchGraphPostForm(`/${accountId}/adcreatives`, token, {
name: args.name,
asset_feed_spec: assetFeedSpec,
});
} else {
requireFields(args, ['image_hash'], toolName);
const objectStorySpec: any = {
page_id: args.page_id,
link_data: {
image_hash: args.image_hash,
link: args.link,
message: args.message,
name: args.headline || 'Headline',
description: args.description || '',
call_to_action: {
type: args.call_to_action_type || 'LEARN_MORE',
value: { link: args.link }
}
}
};
if (args.instagram_actor_id) {
objectStorySpec.instagram_actor_id = args.instagram_actor_id;
}
resultData = await fetchGraphPostForm(`/${accountId}/adcreatives`, token, {
name: args.name,
object_story_spec: objectStorySpec,
});
}
return { content: [{ type: 'text', text: JSON.stringify(resultData) }] };
case 'create_whatsapp_ad_creative':
requireFields(args, ['name', 'page_id', 'message'], toolName);
if (args.image_hashes && Array.isArray(args.image_hashes) && args.image_hashes.length > 0) {
const assetFeedSpecWA = {
images: args.image_hashes.map((hash: any) => ({ hash })),
bodies: [{ text: args.message }],
titles: [{ text: args.headline || 'Headline' }],
descriptions: [{ text: args.description || '' }],
call_to_action_types: ['WHATSAPP_MESSAGE'],
link_urls: [{ website_url: args.link || `https://wa.me/${args.whatsapp_phone_number}` }]
};
resultData = await fetchGraphPostForm(`/${accountId}/adcreatives`, token, {
name: args.name,
asset_feed_spec: assetFeedSpecWA,
});
} else {
requireFields(args, ['image_hash'], toolName);
const waStorySpec: any = {
page_id: args.page_id,
link_data: {
image_hash: args.image_hash,
message: args.message,
name: args.headline || 'Headline',
description: args.description || '',
call_to_action: {
type: 'WHATSAPP_MESSAGE',
value: {
link: args.link || `https://wa.me/${args.whatsapp_phone_number}`
}
}
}
};
if (args.instagram_actor_id) {
waStorySpec.instagram_actor_id = args.instagram_actor_id;
}
resultData = await fetchGraphPostForm(`/${accountId}/adcreatives`, token, {
name: args.name,
object_story_spec: waStorySpec,
});
}
return { content: [{ type: 'text', text: JSON.stringify(resultData) }] };
case 'create_ad':
requireFields(args, ['name', 'adset_id', 'creative_id'], toolName);
resultData = await fetchGraphPostForm(`/${accountId}/ads`, token, {
name: args.name,
adset_id: args.adset_id,
creative: { creative_id: args.creative_id },
status: args.status || 'PAUSED',
});
return { content: [{ type: 'text', text: JSON.stringify(resultData) }] };
case 'upload_ad_image': {
const formData = new FormData();
formData.append('access_token', token);
if (args.image_bytes) {
let base64Data = args.image_bytes;
let mime = 'image/jpeg';
if (base64Data.startsWith('data:image')) {
const parts = base64Data.split(',');
mime = parts[0].match(/:(.*?);/)?.[1] || mime;
base64Data = parts[1];
}
const buffer = Buffer.from(base64Data, 'base64');
formData.append('filename', new Blob([buffer], { type: mime }), 'upload.jpg');
} else if (args.image_url) {
const imgRes = await fetch(args.image_url);
const imgBuffer = await imgRes.arrayBuffer();
formData.append('filename', new Blob([imgBuffer], { type: imgRes.headers.get('content-type') || 'image/png' }));
} else {
throw new Error('Must provide either image_url or image_bytes');
}
const uploadRes = await fetch(`https://graph.facebook.com/v20.0/${accountId}/adimages`, {
method: 'POST',
body: formData,
});
const uploadData = await uploadRes.json();
if (uploadData.error) throwMetaError(uploadData.error);
const hashObj = Object.values(uploadData.images)[0] as any;
return { content: [{ type: 'text', text: JSON.stringify({ hash: hashObj?.hash }) }] };
}
default:
throw new Error(`Tool ${toolName} not implemented in local Graph wrapper`);
}
} catch (error: any) {
console.error(`[Graph API Error] Tool ${toolName}:`, error.message);
throw error;
}
}

615
src/lib/mcp/tools.ts Normal file
View file

@ -0,0 +1,615 @@
// ============================================
// MCP Tools — Typed wrappers for Meta MCP tools
// ============================================
import { callMetaTool, type Client } from './client';
// ---- Helper: extract text content from MCP result ----
function extractContent(result: Awaited<ReturnType<typeof callMetaTool>>): unknown {
if (!result.content || !Array.isArray(result.content)) return null;
const textPart = result.content.find((c: { type: string }) => c.type === 'text');
if (!textPart || !('text' in textPart)) return null;
try {
return JSON.parse(textPart.text as string);
} catch {
return textPart.text;
}
}
function requireId(value: unknown, label: string) {
if (!value || typeof value !== 'string') {
throw new Error(`${label} obrigatório não informado`);
}
}
function getDefaultOptimizationGoal(objective: string, destination?: string) {
if (destination === 'WHATSAPP') {
return 'CONVERSATIONS';
}
switch (objective) {
case 'OUTCOME_TRAFFIC':
return 'LINK_CLICKS';
case 'OUTCOME_AWARENESS':
return 'REACH';
case 'OUTCOME_ENGAGEMENT':
return 'POST_ENGAGEMENT';
case 'OUTCOME_LEADS':
return 'LEAD_GENERATION';
case 'OUTCOME_SALES':
// OFFSITE_CONVERSIONS requires a pixel_id. Since we don't have it in MVP, fallback to LINK_CLICKS
return 'LINK_CLICKS';
default:
return 'LINK_CLICKS';
}
}
function getDefaultBillingEvent(objective: string) {
switch (objective) {
case 'OUTCOME_TRAFFIC':
case 'OUTCOME_AWARENESS':
case 'OUTCOME_ENGAGEMENT':
case 'OUTCOME_LEADS':
case 'OUTCOME_SALES':
default:
return 'IMPRESSIONS';
}
}
function normalizeBidCapParams(params: {
bid_strategy?: string;
bid_amount?: number;
}) {
if (!params.bid_strategy && !params.bid_amount) return {};
const bidStrategy = params.bid_strategy || 'LOWEST_COST_WITH_BID_CAP';
if (bidStrategy === 'LOWEST_COST_WITH_BID_CAP' && !params.bid_amount) {
throw new Error('Para campanha Bid Cap, informe bid_amount em centavos. Exemplo: R$10,00 = 1000.');
}
return {
bid_strategy: bidStrategy,
bid_amount: params.bid_amount,
};
}
// ==============================
// Campaign Management
// ==============================
export async function listCampaigns(client: Client, accountId?: string, datePreset: string = 'last_30d') {
const args: Record<string, unknown> = { date_preset: datePreset };
if (accountId) args.ad_account_id = accountId;
const result = await callMetaTool(client, 'list_campaigns', args);
return extractContent(result);
}
export async function getCampaignDetails(
client: Client,
campaignId: string,
datePreset: string = 'last_30d'
) {
requireId(campaignId, 'campaignId');
const result = await callMetaTool(client, 'get_campaign_details', {
campaign_id: campaignId,
date_preset: datePreset,
});
return extractContent(result);
}
export async function pauseCampaign(client: Client, campaignId: string) {
requireId(campaignId, 'campaignId');
const result = await callMetaTool(client, 'update_campaign', {
campaign_id: campaignId,
status: 'PAUSED',
});
return extractContent(result);
}
export async function resumeCampaign(client: Client, campaignId: string) {
requireId(campaignId, 'campaignId');
const result = await callMetaTool(client, 'update_campaign', {
campaign_id: campaignId,
status: 'ACTIVE',
});
return extractContent(result);
}
/**
* Mantido por compatibilidade.
* ATENÇÃO: orçamento normalmente é no AdSet, não na campanha.
* Para atualizar orçamento real, prefira updateAdSetBudget().
*/
export async function updateCampaignBudget(
client: Client,
campaignId: string,
dailyBudget: number
) {
requireId(campaignId, 'campaignId');
throw new Error(
`updateCampaignBudget não deve atualizar daily_budget direto na campanha.
Use listAdSets(client, campaignId), pegue o adset_id e chame updateAdSetBudget(client, adsetId, dailyBudget).`
);
}
// ==============================
// Insights & Analytics
// ==============================
export async function getCampaignInsights(
client: Client,
campaignId: string,
datePreset: string = 'last_7d'
) {
requireId(campaignId, 'campaignId');
const result = await callMetaTool(client, 'get_insights_for_campaign', {
campaign_id: campaignId,
date_preset: datePreset,
});
return extractContent(result);
}
export async function getAccountInsights(
client: Client,
datePreset: string = 'last_7d',
accountId?: string
) {
const args: Record<string, unknown> = {
date_preset: datePreset,
};
if (accountId) args.ad_account_id = accountId;
const result = await callMetaTool(client, 'get_insights_for_account', args);
return extractContent(result);
}
export async function getAdAccounts(client: Client) {
const result = await callMetaTool(client, 'list_ad_accounts');
return extractContent(result);
}
export async function getPages(client: Client) {
const result = await callMetaTool(client, 'list_pages');
return extractContent(result);
}
// ==============================
// Ad Set Management
// ==============================
export async function listAdSets(client: Client, campaignId: string) {
requireId(campaignId, 'campaignId');
const result = await callMetaTool(client, 'list_adsets', {
campaign_id: campaignId,
});
return extractContent(result);
}
export async function updateAdSetBudget(
client: Client,
adSetId: string,
dailyBudget: number
) {
requireId(adSetId, 'adSetId');
const result = await callMetaTool(client, 'update_adset', {
adset_id: adSetId,
daily_budget: dailyBudget,
});
return extractContent(result);
}
export async function pauseAdSet(client: Client, adSetId: string) {
requireId(adSetId, 'adSetId');
const result = await callMetaTool(client, 'update_adset', {
adset_id: adSetId,
status: 'PAUSED',
});
return extractContent(result);
}
export async function resumeAdSet(client: Client, adSetId: string) {
requireId(adSetId, 'adSetId');
const result = await callMetaTool(client, 'update_adset', {
adset_id: adSetId,
status: 'ACTIVE',
});
return extractContent(result);
}
// ==============================
// Campaign Creation
// ==============================
export async function createCampaign(
client: Client,
params: {
name: string;
objective: string;
status?: string;
ad_account_id?: string;
}
) {
const result = await callMetaTool(client, 'create_campaign', {
name: params.name,
objective: params.objective,
status: params.status || 'PAUSED',
ad_account_id: params.ad_account_id,
});
return extractContent(result);
}
export async function createAdSet(
client: Client,
params: {
name: string;
campaign_id: string;
daily_budget: number;
targeting: Record<string, unknown>;
objective: string;
optimization_goal?: string;
billing_event?: string;
status?: string;
promoted_object?: Record<string, unknown>;
destination_type?: string;
bid_strategy?: string;
bid_amount?: number;
start_time?: string;
end_time?: string;
ad_account_id?: string;
}
) {
const bidCapParams: any = {};
if (params.bid_strategy) bidCapParams['bid_strategy'] = params.bid_strategy;
if (params.bid_amount) bidCapParams['bid_amount'] = params.bid_amount;
const result = await callMetaTool(client, 'create_adset', {
name: params.name,
campaign_id: params.campaign_id,
daily_budget: params.daily_budget,
targeting: params.targeting,
objective: params.objective,
optimization_goal:
params.optimization_goal ||
getDefaultOptimizationGoal(params.objective, params.destination_type),
billing_event: params.billing_event || getDefaultBillingEvent(params.objective),
status: params.status || 'PAUSED',
promoted_object: params.promoted_object,
destination_type: params.destination_type,
start_time: params.start_time,
end_time: params.end_time,
ad_account_id: params.ad_account_id,
...bidCapParams,
});
return extractContent(result);
}
export async function createAd(
client: Client,
params: {
name: string;
adset_id: string;
creative_id: string;
status?: string;
ad_account_id?: string;
}
) {
const result = await callMetaTool(client, 'create_ad', {
name: params.name,
adset_id: params.adset_id,
creative_id: params.creative_id,
status: params.status || 'PAUSED',
ad_account_id: params.ad_account_id,
});
return extractContent(result);
}
export async function createAdCreative(
client: Client,
params: {
name: string;
page_id: string;
instagram_actor_id?: string;
image_hash?: string;
image_hashes?: string[];
link: string;
message: string;
headline: string;
description?: string;
call_to_action_type?: string;
ad_account_id?: string;
}
) {
const result = await callMetaTool(client, 'create_ad_creative', {
name: params.name,
page_id: params.page_id,
instagram_actor_id: params.instagram_actor_id,
image_hash: params.image_hash,
image_hashes: params.image_hashes,
link: params.link,
message: params.message,
headline: params.headline,
description: params.description || '',
call_to_action_type: params.call_to_action_type || 'LEARN_MORE',
ad_account_id: params.ad_account_id,
});
return extractContent(result);
}
export async function createWhatsappAdCreative(
client: Client,
params: {
name: string;
page_id: string;
instagram_actor_id?: string;
image_hash?: string;
image_hashes?: string[];
message: string;
headline: string;
description?: string;
link?: string;
whatsapp_link?: string;
whatsapp_phone_number?: string;
ad_account_id?: string;
}
) {
const result = await callMetaTool(client, 'create_whatsapp_ad_creative', {
name: params.name,
page_id: params.page_id,
instagram_actor_id: params.instagram_actor_id,
image_hash: params.image_hash,
image_hashes: params.image_hashes,
message: params.message,
headline: params.headline,
description: params.description || '',
link: params.link,
whatsapp_link: params.whatsapp_link,
whatsapp_phone_number: params.whatsapp_phone_number,
ad_account_id: params.ad_account_id,
});
return extractContent(result);
}
export async function uploadAdImage(
client: Client,
imageUrl?: string,
imageBytes?: string,
adAccountId?: string
) {
const params: Record<string, string> = {};
if (imageUrl) params.image_url = imageUrl;
if (imageBytes) params.image_bytes = imageBytes;
if (adAccountId) params.ad_account_id = adAccountId;
const result = await callMetaTool(client, 'upload_ad_image', params);
return extractContent(result);
}
// ==============================
// Full Campaign Creation Pipeline
// ==============================
export interface FullCampaignParams {
campaign_name: string;
objective: string;
daily_budget: number;
targeting: Record<string, unknown>;
page_id: string;
instagram_actor_id?: string;
image_urls?: string[];
image_bytes?: string[];
link: string;
headline: string;
primary_text: string;
description: string;
cta: string;
ad_account_id?: string;
bid_strategy?: string;
bid_amount?: number;
optimization_goal?: string;
billing_event?: string;
destination_type?: string;
promoted_object?: Record<string, unknown>;
is_whatsapp?: boolean;
whatsapp_link?: string;
whatsapp_phone_number?: string;
}
export async function createFullCampaign(client: Client, params: FullCampaignParams) {
// Step 1: Campaign
const campaign = await createCampaign(client, {
name: params.campaign_name,
objective: params.objective,
ad_account_id: params.ad_account_id,
});
const campaignId = (campaign as any)?.id;
if (!campaignId) {
throw new Error(`[Step 1 - Campaign] Falha: ${JSON.stringify(campaign)}`);
}
// Step 2: Ad Set
const destinationType = params.destination_type || (params.is_whatsapp ? 'WHATSAPP' : undefined);
let promotedObject = params.promoted_object;
if (!promotedObject && params.is_whatsapp) {
promotedObject = {
page_id: params.page_id,
};
}
const optimizationGoal =
params.optimization_goal ||
getDefaultOptimizationGoal(params.objective, destinationType);
const billingEvent =
params.billing_event ||
getDefaultBillingEvent(params.objective);
const adSet = await createAdSet(client, {
name: `${params.campaign_name} — Ad Set`,
campaign_id: campaignId,
daily_budget: params.daily_budget,
targeting: params.targeting,
objective: params.objective,
optimization_goal: optimizationGoal,
billing_event: billingEvent,
promoted_object: promotedObject,
destination_type: destinationType,
bid_strategy: params.bid_strategy,
bid_amount: params.bid_amount,
ad_account_id: params.ad_account_id,
});
const adSetId = (adSet as any)?.id;
if (!adSetId) {
throw new Error(`[Step 2 - Ad Set] Falha: ${JSON.stringify(adSet)}`);
}
// Step 3: Image Uploads
const imageHashes: string[] = [];
const DATA_URI_RE = /^data:image\/[a-zA-Z0-9.+-]+;base64,(.+)$/;
const processImageUploads = async (urls?: string[], bytes?: string[]) => {
const allBytes = [...(bytes || [])];
const realUrls: string[] = [];
// gpt-image-2 returns base64 data URIs (data:image/png;base64,...) — Meta's
// upload_ad_image expects those as raw base64 via image_bytes, not image_url
// (sending a data URI as image_url causes "tipo de arquivo não suportado").
for (const url of urls || []) {
const match = DATA_URI_RE.exec(url);
if (match) {
allBytes.push(match[1]);
} else {
realUrls.push(url);
}
}
for (const url of realUrls) {
const res = await uploadAdImage(client, url, undefined, params.ad_account_id);
if ((res as any)?.hash) imageHashes.push((res as any).hash);
}
for (const b of allBytes) {
const res = await uploadAdImage(client, undefined, b, params.ad_account_id);
if ((res as any)?.hash) imageHashes.push((res as any).hash);
}
};
await processImageUploads(params.image_urls, params.image_bytes);
if (imageHashes.length === 0) {
throw new Error('[Step 3 - Upload] Nenhuma imagem foi enviada ou processada.');
}
// Step 4 & 5: Create Ad Creative and Ad
const creativeName = `${params.campaign_name} — Creative`;
// Use a single primary image via the standard object_story_spec path —
// asset_feed_spec (Asset Customization / Dynamic Creative) requires the ad
// set to be created with is_dynamic_creative=true, which we don't set here,
// and combining it with degrees_of_freedom_spec triggers Meta error subcode
// 2490433 ("unexpected error"). The first generated image (1080x1080) is
// the most broadly compatible across placements.
const primaryImageHash = imageHashes[0];
let creative;
if (params.is_whatsapp) {
creative = await createWhatsappAdCreative(client, {
name: creativeName,
page_id: params.page_id,
instagram_actor_id: params.instagram_actor_id,
image_hash: primaryImageHash,
message: params.primary_text,
headline: params.headline,
description: params.description,
link: params.link,
whatsapp_link: params.whatsapp_link,
whatsapp_phone_number: params.whatsapp_phone_number,
ad_account_id: params.ad_account_id,
});
} else {
creative = await createAdCreative(client, {
name: creativeName,
page_id: params.page_id,
instagram_actor_id: params.instagram_actor_id,
image_hash: primaryImageHash,
link: params.link,
message: params.primary_text,
headline: params.headline,
description: params.description,
call_to_action_type: params.cta,
ad_account_id: params.ad_account_id,
});
}
const creativeId = (creative as any)?.id;
if (!creativeId) {
throw new Error(`[Step 4 - Creative] Falha no criativo: ${JSON.stringify(creative)}`);
}
const ad = await createAd(client, {
name: `${params.campaign_name} — Ad`,
adset_id: adSetId,
creative_id: creativeId,
ad_account_id: params.ad_account_id,
});
const adId = (ad as any)?.id;
if (!adId) {
throw new Error(`[Step 5 - Ad] Falha no anúncio: ${JSON.stringify(ad)}`);
}
return {
campaign_id: campaignId,
adset_id: adSetId,
creative_ids: [creativeId],
ad_ids: [adId],
objective: params.objective,
optimization_goal: optimizationGoal,
billing_event: billingEvent,
bid_strategy: params.bid_strategy,
bid_amount: params.bid_amount,
is_whatsapp: params.is_whatsapp || false,
};
}

8
src/lib/meta/create.ts Normal file
View file

@ -0,0 +1,8 @@
// ============================================
// Meta Campaign Creation — Via MCP
// ============================================
// Re-exports the MCP tools for campaign creation
// This file exists for backward compatibility
export { createFullCampaign, createCampaign, createAdSet, createAd, createAdCreative } from '@/lib/mcp/tools';
export type { FullCampaignParams } from '@/lib/mcp/tools';

169
src/lib/meta/targeting.ts Normal file
View file

@ -0,0 +1,169 @@
// ============================================
// Meta Marketing API — Targeting Templates
// ============================================
export interface TargetingTemplate {
id: string;
name: string;
description: string;
targeting: Record<string, unknown>;
}
export const TARGETING_TEMPLATES: TargetingTemplate[] = [
{
id: 'broad_brazil',
name: 'Brasil — Amplo',
description: '18-65, Homens e Mulheres, Todo o Brasil',
targeting: {
geo_locations: { countries: ['BR'] },
age_min: 18,
age_max: 65,
},
},
{
id: 'sp_rj_adults',
name: 'SP + RJ — Adultos',
description:
'25-55, Homens e Mulheres, São Paulo e Rio de Janeiro',
targeting: {
geo_locations: {
regions: [
{ key: '2648' },
{ key: '2647' },
],
},
age_min: 25,
age_max: 55,
},
},
{
id: 'ecommerce_buyers',
name: 'Compradores Online',
description:
'21-50, Interesse em compras online',
targeting: {
geo_locations: {
countries: ['BR'],
},
age_min: 21,
age_max: 50,
interests: [
{
id: '6003107902433',
name: 'Online shopping',
},
{
id: '6003986592572',
name: 'E-commerce',
},
],
behaviors: [
{
id: '6071631541183',
name: 'Engaged Shoppers',
},
],
},
},
{
id: 'entrepreneurs',
name: 'Empreendedores',
description:
'25-55, Donos de negócios e empreendedores',
targeting: {
geo_locations: {
countries: ['BR'],
},
age_min: 25,
age_max: 55,
interests: [
{
id: '6003384890556',
name: 'Entrepreneurship',
},
{
id: '6003629266583',
name: 'Small business',
},
{
id: '6003020834693',
name: 'Business',
},
],
},
},
{
id: 'health_fitness',
name: 'Saúde & Fitness',
description:
'18-45, Interesse em saúde e exercícios',
targeting: {
geo_locations: {
countries: ['BR'],
},
age_min: 18,
age_max: 45,
interests: [
{
id: '6003384425556',
name: 'Physical fitness',
},
{
id: '6003659945856',
name: 'Health',
},
{
id: '6003107405556',
name: 'Gym',
},
],
},
},
{
id: 'parents_kids',
name: 'Mães e Pais',
description: '25-45, Pais e Mães (Público Amplo / Advantage+)',
targeting: {
geo_locations: {
countries: ['BR'],
},
age_min: 25,
age_max: 45,
},
},
];
export function getTargetingTemplate(
id: string
): TargetingTemplate | undefined {
return TARGETING_TEMPLATES.find(
(t) => t.id === id
);
}

126
src/lib/meta/types.ts Normal file
View file

@ -0,0 +1,126 @@
// ============================================
// Meta Marketing API — TypeScript Types
// ============================================
export interface Ad {
id: string;
name: string;
status: 'ACTIVE' | 'PAUSED' | 'DELETED' | 'ARCHIVED';
insights?: {
spend: string;
ctr: string;
cpc: string;
cpm: string;
leads: number;
roas: number;
};
}
export interface AdSet {
id: string;
name: string;
status: 'ACTIVE' | 'PAUSED' | 'DELETED' | 'ARCHIVED';
daily_budget?: string;
ads?: {
data: Ad[];
};
insights?: {
spend: string;
ctr: string;
cpc: string;
cpm: string;
leads: number;
roas: number;
};
}
export interface Campaign {
id: string;
name: string;
status: 'ACTIVE' | 'PAUSED' | 'DELETED' | 'ARCHIVED';
objective: string;
daily_budget: string;
lifetime_budget: string;
budget_remaining: string;
created_time: string;
updated_time: string;
start_time?: string;
stop_time?: string;
special_ad_categories: string[];
adsets?: {
data: AdSet[];
};
}
export interface CampaignInsights {
campaign_id: string;
campaign_name: string;
spend: string;
impressions: string;
clicks: string;
reach: string;
ctr: string;
cpc: string;
cpm: string;
cpp: string;
actions?: ActionItem[];
cost_per_action_type?: ActionItem[];
purchase_roas?: RoasItem[];
date_start: string;
date_stop: string;
}
export interface ActionItem {
action_type: string;
value: string;
}
export interface RoasItem {
action_type: string;
value: string;
}
export interface CampaignWithInsights {
campaign: Campaign;
insights: CampaignInsights | null;
}
export interface AccountOverview {
total_spend: number;
total_impressions: number;
total_clicks: number;
total_reach: number;
total_leads: number;
avg_ctr: number;
avg_cpc: number;
avg_cpm: number;
total_roas: number;
campaigns_active: number;
campaigns_paused: number;
campaigns_total: number;
}
export interface MetricsCardData {
title: string;
value: string;
change?: number;
changeLabel?: string;
icon: string;
color: string;
}
export type DatePreset =
| 'today'
| 'yesterday'
| 'last_7d'
| 'last_14d'
| 'last_30d'
| 'this_month'
| 'last_month'
| 'lifetime';
export interface ApiResponse<T> {
success: boolean;
data?: T;
error?: string;
}

34
src/lib/settings.ts Normal file
View file

@ -0,0 +1,34 @@
import fs from 'fs';
import path from 'path';
const SETTINGS_FILE = path.join(process.cwd(), 'settings.json');
export interface AppSettings {
metaAppId: string;
metaAppSecret: string;
openaiApiKey: string;
}
const defaultSettings: AppSettings = {
metaAppId: '',
metaAppSecret: '',
openaiApiKey: '',
};
export function getSettings(): AppSettings {
try {
if (fs.existsSync(SETTINGS_FILE)) {
const data = fs.readFileSync(SETTINGS_FILE, 'utf-8');
return { ...defaultSettings, ...JSON.parse(data) };
}
} catch (error) {
console.error('Error reading settings:', error);
}
return defaultSettings;
}
export function saveSettings(settings: Partial<AppSettings>): void {
const current = getSettings();
const updated = { ...current, ...settings };
fs.writeFileSync(SETTINGS_FILE, JSON.stringify(updated, null, 2), 'utf-8');
}

View file

@ -0,0 +1,20 @@
import { createClient } from '@supabase/supabase-js';
let client: ReturnType<typeof createClient> | null = null;
export function getSupabaseServerClient() {
if (client) return client;
const url = process.env.NEXT_PUBLIC_SUPABASE_URL;
const key = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
if (!url || !key) {
throw new Error('Supabase não está configurado (NEXT_PUBLIC_SUPABASE_URL / NEXT_PUBLIC_SUPABASE_ANON_KEY ausentes).');
}
client = createClient(url, key, {
auth: { persistSession: false },
});
return client;
}

31
src/middleware.ts Normal file
View file

@ -0,0 +1,31 @@
// ============================================
// Middleware — Cookie-based auth guard
// ============================================
import { NextRequest, NextResponse } from 'next/server';
export function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
// Only protect dashboard routes
if (!pathname.startsWith('/dashboard')) {
return NextResponse.next();
}
// Check for OAuth token in cookies
const token = request.cookies.get('meta_access_token')?.value;
if (!token) {
// Dev mode: allow access without token for local testing
if (process.env.NODE_ENV === 'development') {
return NextResponse.next();
}
return NextResponse.redirect(new URL('/login', request.url));
}
return NextResponse.next();
}
export const config = {
matcher: ['/dashboard/:path*'],
};

78
supabase/migration.sql Normal file
View file

@ -0,0 +1,78 @@
-- ============================================
-- MetaAds Pro — Supabase Migration
-- Execute this in Supabase SQL Editor
-- ============================================
-- 1. Automation Rules
CREATE TABLE IF NOT EXISTS automation_rules (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL,
enabled BOOLEAN DEFAULT true,
metric TEXT NOT NULL CHECK (metric IN ('cpl', 'cpc', 'ctr', 'roas', 'cpm', 'spend')),
operator TEXT NOT NULL CHECK (operator IN ('>', '<', '>=', '<=')),
threshold NUMERIC NOT NULL,
min_spend NUMERIC DEFAULT 0,
min_impressions INTEGER DEFAULT 0,
action TEXT NOT NULL CHECK (action IN ('pause', 'reduce_budget', 'increase_budget', 'notify')),
action_value NUMERIC,
cooldown_hours INTEGER DEFAULT 24,
created_at TIMESTAMPTZ DEFAULT now(),
updated_at TIMESTAMPTZ DEFAULT now()
);
-- 2. Agent Activity Log
CREATE TABLE IF NOT EXISTS agent_activity_log (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
rule_id UUID REFERENCES automation_rules(id) ON DELETE SET NULL,
campaign_id TEXT NOT NULL,
campaign_name TEXT NOT NULL,
action TEXT NOT NULL,
details JSONB DEFAULT '{}',
status TEXT DEFAULT 'executed' CHECK (status IN ('executed', 'pending', 'failed', 'skipped')),
created_at TIMESTAMPTZ DEFAULT now()
);
-- 3. Agent-Created Campaigns
CREATE TABLE IF NOT EXISTS agent_campaigns (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
meta_campaign_id TEXT,
meta_adset_id TEXT,
meta_creative_id TEXT,
meta_ad_id TEXT,
product TEXT NOT NULL,
audience TEXT,
objective TEXT,
budget_daily NUMERIC,
copy_variations JSONB DEFAULT '[]',
image_urls JSONB DEFAULT '[]',
targeting JSONB DEFAULT '{}',
status TEXT DEFAULT 'draft' CHECK (status IN ('draft', 'pending', 'published', 'active', 'paused', 'failed')),
approved_at TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT now()
);
-- 4. Indexes
CREATE INDEX IF NOT EXISTS idx_activity_campaign ON agent_activity_log(campaign_id);
CREATE INDEX IF NOT EXISTS idx_activity_created ON agent_activity_log(created_at DESC);
CREATE INDEX IF NOT EXISTS idx_activity_status ON agent_activity_log(status);
CREATE INDEX IF NOT EXISTS idx_rules_enabled ON automation_rules(enabled);
CREATE INDEX IF NOT EXISTS idx_campaigns_status ON agent_campaigns(status);
-- 5. Row Level Security (RLS) — adjust per your needs
ALTER TABLE automation_rules ENABLE ROW LEVEL SECURITY;
ALTER TABLE agent_activity_log ENABLE ROW LEVEL SECURITY;
ALTER TABLE agent_campaigns ENABLE ROW LEVEL SECURITY;
-- Allow authenticated users full access (single-tenant setup)
CREATE POLICY "Allow all for authenticated" ON automation_rules FOR ALL USING (auth.role() = 'authenticated');
CREATE POLICY "Allow all for authenticated" ON agent_activity_log FOR ALL USING (auth.role() = 'authenticated');
CREATE POLICY "Allow all for authenticated" ON agent_campaigns FOR ALL USING (auth.role() = 'authenticated');
-- 6. Insert default rules
INSERT INTO automation_rules (name, metric, operator, threshold, min_spend, min_impressions, action, cooldown_hours, enabled) VALUES
('CPL Alto — Pausar', 'cpl', '>', 50, 30, 1000, 'pause', 24, true),
('ROAS Baixo — Pausar', 'roas', '<', 0.5, 50, 2000, 'pause', 24, true),
('CTR Muito Baixo — Pausar', 'ctr', '<', 0.5, 20, 2000, 'pause', 24, true),
('CPC Alto — Reduzir Budget 30%', 'cpc', '>', 5, 30, 1000, 'reduce_budget', 48, false),
('ROAS Excelente — Aumentar Budget 20%', 'roas', '>', 3, 100, 5000, 'increase_budget', 48, false)
ON CONFLICT DO NOTHING;

View file

@ -30,5 +30,5 @@
".next/dev/types/**/*.ts",
"**/*.mts"
],
"exclude": ["node_modules"]
"exclude": ["node_modules", "mcp-server"]
}