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:
parent
b955aee644
commit
234314d1c6
87 changed files with 13166 additions and 125 deletions
1
.claude/scheduled_tasks.lock
Normal file
1
.claude/scheduled_tasks.lock
Normal 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
6
.gitignore
vendored
|
|
@ -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
12
mcp-config.json
Normal 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
243
mcp-server/index.ts
Normal 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
19
mcp-server/package.json
Normal 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
17
mcp-server/tsconfig.json
Normal 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
93
n8n-workflow.json
Normal 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
3249
package-lock.json
generated
File diff suppressed because it is too large
Load diff
12
package.json
12
package.json
|
|
@ -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",
|
||||
|
|
|
|||
29
src/app/api/ai/analyze-link/route.ts
Normal file
29
src/app/api/ai/analyze-link/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
29
src/app/api/ai/generate-audience/route.ts
Normal file
29
src/app/api/ai/generate-audience/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
30
src/app/api/ai/generate-copy/route.ts
Normal file
30
src/app/api/ai/generate-copy/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
41
src/app/api/ai/generate-image/route.ts
Normal file
41
src/app/api/ai/generate-image/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
53
src/app/api/analysis/chat/route.ts
Normal file
53
src/app/api/analysis/chat/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
144
src/app/api/analysis/route.ts
Normal file
144
src/app/api/analysis/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
81
src/app/api/auth/callback/route.ts
Normal file
81
src/app/api/auth/callback/route.ts
Normal 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)
|
||||
);
|
||||
}
|
||||
}
|
||||
13
src/app/api/auth/logout/route.ts
Normal file
13
src/app/api/auth/logout/route.ts
Normal 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));
|
||||
}
|
||||
37
src/app/api/auth/meta/route.ts
Normal file
37
src/app/api/auth/meta/route.ts
Normal 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());
|
||||
}
|
||||
45
src/app/api/campaigns/[id]/budget/route.ts
Normal file
45
src/app/api/campaigns/[id]/budget/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
50
src/app/api/campaigns/[id]/status/route.ts
Normal file
50
src/app/api/campaigns/[id]/status/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
270
src/app/api/campaigns/create/route.ts
Normal file
270
src/app/api/campaigns/create/route.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
24
src/app/api/campaigns/draft/[id]/route.ts
Normal file
24
src/app/api/campaigns/draft/[id]/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
93
src/app/api/campaigns/draft/route.ts
Normal file
93
src/app/api/campaigns/draft/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
53
src/app/api/campaigns/insights/route.ts
Normal file
53
src/app/api/campaigns/insights/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
87
src/app/api/campaigns/route.ts
Normal file
87
src/app/api/campaigns/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
70
src/app/api/cron/monitor/route.ts
Normal file
70
src/app/api/cron/monitor/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
15
src/app/api/debug/route.ts
Normal file
15
src/app/api/debug/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
67
src/app/api/engine/rules/route.ts
Normal file
67
src/app/api/engine/rules/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
44
src/app/api/engine/run/route.ts
Normal file
44
src/app/api/engine/run/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
43
src/app/api/meta/pages/route.ts
Normal file
43
src/app/api/meta/pages/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
24
src/app/api/settings/route.ts
Normal file
24
src/app/api/settings/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
151
src/app/dashboard/activity/page.tsx
Normal file
151
src/app/dashboard/activity/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
462
src/app/dashboard/analysis/page.tsx
Normal file
462
src/app/dashboard/analysis/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
261
src/app/dashboard/automation/page.tsx
Normal file
261
src/app/dashboard/automation/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
1438
src/app/dashboard/create/page.tsx
Normal file
1438
src/app/dashboard/create/page.tsx
Normal file
File diff suppressed because it is too large
Load diff
321
src/app/dashboard/history/page.tsx
Normal file
321
src/app/dashboard/history/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
15
src/app/dashboard/layout.tsx
Normal file
15
src/app/dashboard/layout.tsx
Normal 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
220
src/app/dashboard/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
197
src/app/dashboard/settings/page.tsx
Normal file
197
src/app/dashboard/settings/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
65
src/app/data-deletion/page.tsx
Normal file
65
src/app/data-deletion/page.tsx
Normal 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 já 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
96
src/app/login/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
248
src/app/page.tsx
248
src/app/page.tsx
|
|
@ -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
85
src/app/privacy/page.tsx
Normal 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
34
src/app/setup/page.tsx
Normal 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
84
src/app/terms/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
108
src/components/dashboard/BudgetModal.tsx
Normal file
108
src/components/dashboard/BudgetModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
265
src/components/dashboard/CampaignTable.tsx
Normal file
265
src/components/dashboard/CampaignTable.tsx
Normal 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} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
40
src/components/dashboard/DateFilter.tsx
Normal file
40
src/components/dashboard/DateFilter.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
142
src/components/dashboard/MetricsCard.tsx
Normal file
142
src/components/dashboard/MetricsCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
133
src/components/dashboard/Sidebar.tsx
Normal file
133
src/components/dashboard/Sidebar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
39
src/components/public/AnimatedCounter.tsx
Normal file
39
src/components/public/AnimatedCounter.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
37
src/components/public/FloatingOrbs.tsx
Normal file
37
src/components/public/FloatingOrbs.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
130
src/components/public/HowItWorks.tsx
Normal file
130
src/components/public/HowItWorks.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
36
src/components/public/LegalPage.tsx
Normal file
36
src/components/public/LegalPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
130
src/components/public/LiveChartMock.tsx
Normal file
130
src/components/public/LiveChartMock.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
19
src/components/public/PublicFooter.tsx
Normal file
19
src/components/public/PublicFooter.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
35
src/components/public/PublicHeader.tsx
Normal file
35
src/components/public/PublicHeader.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
28
src/components/public/ScrollReveal.tsx
Normal file
28
src/components/public/ScrollReveal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
30
src/components/public/StatsStrip.tsx
Normal file
30
src/components/public/StatsStrip.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
56
src/components/theme/ThemeProvider.tsx
Normal file
56
src/components/theme/ThemeProvider.tsx
Normal 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;
|
||||
}
|
||||
27
src/components/theme/ThemeToggle.tsx
Normal file
27
src/components/theme/ThemeToggle.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
60
src/components/ui/ErrorBlock.tsx
Normal file
60
src/components/ui/ErrorBlock.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
34
src/components/ui/Modal.tsx
Normal file
34
src/components/ui/Modal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
44
src/components/ui/Spinner.tsx
Normal file
44
src/components/ui/Spinner.tsx
Normal 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
208
src/config/skins/index.ts
Normal 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
121
src/lib/ai/analyzer.ts
Normal 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
26
src/lib/ai/client.ts
Normal 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
123
src/lib/ai/copywriter.ts
Normal 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
65
src/lib/ai/creative.ts
Normal 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
68
src/lib/ai/scraper.ts
Normal 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
98
src/lib/ai/strategist.ts
Normal 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
50
src/lib/auth/session.ts
Normal 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
254
src/lib/engine/monitor.ts
Normal 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
132
src/lib/engine/rules.ts
Normal 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
89
src/lib/errors.ts
Normal 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
357
src/lib/mcp/client.ts
Normal 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
615
src/lib/mcp/tools.ts
Normal 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
8
src/lib/meta/create.ts
Normal 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
169
src/lib/meta/targeting.ts
Normal 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
126
src/lib/meta/types.ts
Normal 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
34
src/lib/settings.ts
Normal 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');
|
||||
}
|
||||
20
src/lib/supabase/server.ts
Normal file
20
src/lib/supabase/server.ts
Normal 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
31
src/middleware.ts
Normal 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
78
supabase/migration.sql
Normal 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;
|
||||
|
|
@ -30,5 +30,5 @@
|
|||
".next/dev/types/**/*.ts",
|
||||
"**/*.mts"
|
||||
],
|
||||
"exclude": ["node_modules"]
|
||||
"exclude": ["node_modules", "mcp-server"]
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue