feat: posicionamentos restritos + stats completas + auditoria IA melhorada

- Posicionamentos: novas campanhas saem só com Feed+Stories+Reels (FB+IG),
  sem Audience Network, Messenger, coluna direita ou Marketplace
- Botão "Corrigir Posicionamentos" (ícone Target) em cada ad set existente —
  busca targeting atual, preserva geo/idade/interesses e aplica as restrições
- Stats: compras, alcance, CPA e reach nos cards do dashboard e na tabela
  (campanha, ad set e anúncio); bug de leads/ROAS sempre 0 no nível campanha corrigido
- Auditoria IA: prompt ciente do objetivo (Leads/Vendas/Tráfego), passa todas
  as métricas relevantes (CTR, CPC, CPL, CPA, alcance, impressões); hero da
  tela mostra cards dinâmicos conforme o tipo de campanha
- strategist.ts: corrigido acesso indevido a actions/purchase_roas nos insights mapeados

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Marcio Bevervanso 2026-06-17 14:34:47 -03:00
parent 3ac5086161
commit 4bde1cc5c8
12 changed files with 407 additions and 138 deletions

View file

@ -0,0 +1,32 @@
import { NextRequest, NextResponse } from 'next/server';
import { createMetaMCPClient } from '@/lib/mcp/client';
import { updateAdSetPlacements } 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 client = await createMetaMCPClient(token);
try {
await updateAdSetPlacements(client, id);
return NextResponse.json({ success: true });
} finally {
await client.close();
}
} catch (error) {
console.error('[API] Error updating ad set placements:', error);
return NextResponse.json(
{ success: false, error: error instanceof Error ? error.message : 'Failed to update placements' },
{ status: 500 }
);
}
}

View file

@ -6,6 +6,24 @@ import { getAIClient } from '@/lib/ai/client';
const ANALYSIS_MODEL = 'gpt-5.4-mini'; const ANALYSIS_MODEL = 'gpt-5.4-mini';
// Extrai o valor de uma action pelo tipo
function findAction(actions: any[], type: string): number {
return parseInt(actions.find((a: any) => a.action_type === type)?.value || '0', 10);
}
// Rótulo legível do objetivo da campanha
function objectiveLabel(objective: string): string {
const map: Record<string, string> = {
OUTCOME_LEADS: 'Geração de Leads',
OUTCOME_SALES: 'Vendas (Conversões)',
OUTCOME_TRAFFIC: 'Tráfego',
OUTCOME_AWARENESS: 'Reconhecimento de Marca',
OUTCOME_ENGAGEMENT: 'Engajamento',
OUTCOME_APP_PROMOTION: 'Promoção de App',
};
return map[objective] || objective;
}
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
try { try {
const token = await getAccessToken(); const token = await getAccessToken();
@ -32,108 +50,162 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ success: false, error: 'Campaign not found on Meta' }, { status: 404 }); 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 insights = campaignData.insights?.data?.[0] || {};
const adsets = campaignData.adsets?.data || []; const adsets = campaignData.adsets?.data || [];
const actions = insights.actions || [];
const objective = campaignData.objective || '';
let totalSpend = parseFloat(insights.spend || '0'); const spend = parseFloat(insights.spend || '0');
let roas = parseFloat(insights.purchase_roas?.[0]?.value || '0'); const ctr = parseFloat(insights.ctr || '0');
const cpc = parseFloat(insights.cpc || '0');
const cpm = parseFloat(insights.cpm || '0');
const reach = parseInt(insights.reach || '0', 10);
const impressions = parseInt(insights.impressions || '0', 10);
const leads = findAction(actions, 'lead') || findAction(actions, 'onsite_conversion.lead_grouped');
const purchases = findAction(actions, 'omni_purchase');
const roasRaw = parseFloat(insights.purchase_roas?.[0]?.value || '0');
const cpl = leads > 0 ? spend / leads : 0;
const cpa = purchases > 0 ? spend / purchases : 0;
// Extract Ads & Creatives // Extract Ads & Creatives
const adsToAnalyze = []; const adsToAnalyze: any[] = [];
for (const adset of adsets) { for (const adset of adsets) {
const ads = adset.ads?.data || []; for (const ad of adset.ads?.data || []) {
for (const ad of ads) { const adIns = ad.insights?.data?.[0] || {};
const adInsights = ad.insights?.data?.[0] || {}; const adActions = adIns.actions || [];
const creative = ad.creative || {}; const creative = ad.creative || {};
// Extract texts
let body = creative.body || ''; let body = creative.body || '';
let title = creative.title || ''; let title = creative.title || '';
let imageUrl = creative.image_url || creative.thumbnail_url || ''; let imageUrl = creative.image_url || creative.thumbnail_url || '';
// Handle asset_feed_spec (Dynamic Creative) if (creative.object_story_spec?.link_data) {
const ld = creative.object_story_spec.link_data;
body = body || ld.message || '';
title = title || ld.name || '';
}
if (creative.asset_feed_spec) { if (creative.asset_feed_spec) {
const feed = creative.asset_feed_spec; const feed = creative.asset_feed_spec;
body = feed.bodies?.map((b: any) => b.text).join(' | ') || body; body = body || feed.bodies?.map((b: any) => b.text).join(' | ') || '';
title = feed.titles?.map((t: any) => t.text).join(' | ') || title; title = title || feed.titles?.map((t: any) => t.text).join(' | ') || '';
if (feed.images && feed.images.length > 0) { if (!imageUrl && feed.images?.length > 0) {
imageUrl = feed.images[0].url || feed.images[0].thumbnail_url || imageUrl; // Just take the first image for now imageUrl = feed.images[0].url || feed.images[0].thumbnail_url || '';
} }
} }
const adLeads = findAction(adActions, 'lead') || findAction(adActions, 'onsite_conversion.lead_grouped');
const adPurchases = findAction(adActions, 'omni_purchase');
const adSpend = parseFloat(adIns.spend || '0');
adsToAnalyze.push({ adsToAnalyze.push({
ad_name: ad.name, ad_name: ad.name,
spend: adInsights.spend || '0', spend: adSpend.toFixed(2),
roas: adInsights.purchase_roas?.[0]?.value || '0', ctr: parseFloat(adIns.ctr || '0').toFixed(2) + '%',
cpc: adInsights.cpc || '0', cpc: 'R$ ' + parseFloat(adIns.cpc || '0').toFixed(2),
ctr: adInsights.ctr || '0', leads: adLeads,
body, purchases: adPurchases,
title, cpl: adLeads > 0 ? 'R$ ' + (adSpend / adLeads).toFixed(2) : null,
cpa: adPurchases > 0 ? 'R$ ' + (adSpend / adPurchases).toFixed(2) : null,
roas: adIns.purchase_roas?.[0]?.value ? parseFloat(adIns.purchase_roas[0].value).toFixed(2) + 'x' : null,
body: body || '(sem texto)',
title: title || '(sem título)',
image_url: imageUrl, image_url: imageUrl,
}); });
} }
} }
// Monta o contexto de métricas de forma objetiva para o tipo de campanha
const isLeadGen = objective === 'OUTCOME_LEADS' || (leads > 0 && purchases === 0);
const isSales = objective === 'OUTCOME_SALES' || purchases > 0;
const metricsBlock = [
`Objetivo: ${objectiveLabel(objective)}`,
`Gasto (30d): R$ ${spend.toFixed(2)}`,
`Alcance: ${reach.toLocaleString('pt-BR')} pessoas`,
`Impressões: ${impressions.toLocaleString('pt-BR')}`,
`CTR: ${ctr.toFixed(2)}%`,
`CPC: R$ ${cpc.toFixed(2)}`,
`CPM: R$ ${cpm.toFixed(2)}`,
leads > 0 ? `Leads gerados: ${leads} | CPL: R$ ${cpl.toFixed(2)}` : null,
purchases > 0 ? `Compras: ${purchases} | CPA: R$ ${cpa.toFixed(2)} | ROAS: ${roasRaw.toFixed(2)}x` : null,
].filter(Boolean).join('\n');
const focusInstructions = isLeadGen
? 'Esta é uma campanha de LEADS. Priorize: CPL (custo por lead), volume de leads, qualidade do copy para conversão em formulário.'
: isSales
? 'Esta é uma campanha de VENDAS. Priorize: ROAS, CPA (custo por compra), volume de compras e lucratividade.'
: 'Esta é uma campanha de TRÁFEGO. Priorize: CTR, CPC, qualidade dos cliques e relevância do público.';
const openai = getAIClient(); const openai = getAIClient();
const prompt = ` const prompt = `Você é um Especialista Sênior em Tráfego Pago e Copywriting com foco no mercado brasileiro.
Você é um Especialista Sênior em Tráfego Pago e Copywriting. Faça uma auditoria completa e honesta desta campanha no Meta Ads.
Abaixo estão os dados reais extraídos de uma Campanha no Facebook Ads.
Por favor, faça uma auditoria completa. ${focusInstructions}
DADOS DA CAMPANHA: DADOS DA CAMPANHA:
Nome: ${campaignData.name} Nome: ${campaignData.name}
Gasto Total (30d): R$ ${totalSpend} ${metricsBlock}
ROAS Total: ${roas}
ANÚNCIOS ATIVOS (CRIATIVOS E MÉTRICAS): ANÚNCIOS (CRIATIVOS E MÉTRICAS INDIVIDUAIS):
${JSON.stringify(adsToAnalyze, null, 2)} ${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). Retorne SOMENTE um JSON válido, sem markdown, com esta estrutura exata:
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.", "diagnostico_geral": "Parágrafo forte resumindo saúde da campanha, mencionando a métrica-chave do objetivo (CPL para leads, ROAS/CPA para vendas, CTR para tráfego).",
"analise_copy": ["Ponto 1 sobre a copy...", "Sugestão de melhoria de texto..."], "analise_copy": ["Ponto sobre texto do anúncio X...", "Sugestão específica de melhoria..."],
"analise_imagem": ["O que achei do visual...", "Ideia de novo formato (vídeo/carrossel)..."], "analise_imagem": ["Avaliação do visual...", "Sugestão de formato ou abordagem..."],
"analise_metricas": ["ROAS está X...", "CPA está Y..."], "analise_metricas": ["Métrica X está Y — o que significa...", "Comparativo com benchmark..."],
"acao_recomendada": ["Ação imediata 1 (ex: Pausar Ad X)...", "Ação 2 (ex: Testar público Y)..."], "acao_recomendada": ["Ação imediata 1...", "Ação 2..."],
"nova_estrategia_sugerida": { "nova_estrategia_sugerida": {
"nicho_sugerido": "ex: ecommerce, info_produto, negocios_locais, advogado...", "nicho_sugerido": "ecommerce / info_produto / negocios_locais / etc",
"produto_foco": "Nome ou tipo do produto que deve ser focado na próxima campanha", "produto_foco": "Produto ou oferta que deve ser o foco",
"publico_alvo": "Qual deve ser o público alvo ideal", "publico_alvo": "Público ideal baseado nos dados",
"angulos_de_venda": ["Ângulo 1", "Ângulo 2"] "angulos_de_venda": ["Ângulo 1", "Ângulo 2"]
} }
} }`;
`;
const completion = await openai.chat.completions.create({ const completion = await openai.chat.completions.create({
model: ANALYSIS_MODEL, model: ANALYSIS_MODEL,
messages: [{ role: 'user', content: prompt }], messages: [{ role: 'user', content: prompt }],
response_format: { type: 'json_object' }, response_format: { type: 'json_object' },
}); });
const textRes = completion.choices[0]?.message?.content || ''; const textRes = completion.choices[0]?.message?.content || '';
let jsonRes; let jsonRes: any;
try { try {
// Clean up markdown fences if model ignored instruction const cleaned = textRes.replace(/^```json/m, '').replace(/```$/m, '').trim();
const cleanedText = textRes.replace(/^```json/m, '').replace(/```$/m, '').trim(); jsonRes = JSON.parse(cleaned);
jsonRes = JSON.parse(cleanedText); } catch {
} catch (e) { console.error('[Analysis] Failed to parse AI response:', textRes);
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: false, error: 'Failed to parse AI response' }, { status: 500 });
} }
return NextResponse.json({ return NextResponse.json({
success: true, success: true,
data: { data: {
campaign_metrics: { spend: totalSpend, roas }, campaign_metrics: {
spend: spend.toFixed(2),
ctr: ctr.toFixed(2),
cpc: cpc.toFixed(2),
cpm: cpm.toFixed(2),
reach,
impressions,
leads,
purchases,
cpl: cpl > 0 ? cpl.toFixed(2) : null,
cpa: cpa > 0 ? cpa.toFixed(2) : null,
roas: roasRaw > 0 ? roasRaw.toFixed(2) : null,
objective,
objective_label: objectiveLabel(objective),
},
ads_count: adsToAnalyze.length, ads_count: adsToAnalyze.length,
ads: adsToAnalyze, ads: adsToAnalyze,
analysis: jsonRes analysis: jsonRes,
} },
}); });
} catch (error) { } catch (error) {
console.error('[Analysis] API Error:', error); console.error('[Analysis] API Error:', error);
return NextResponse.json( return NextResponse.json(

View file

@ -257,18 +257,20 @@ export async function POST(req: Request) {
}); });
const baseTargeting = targeting || { geo_locations: { countries: ['BR'] } }; const baseTargeting = targeting || { geo_locations: { countries: ['BR'] } };
const hasIg = !!resolvedInstagramActorId;
// Restringe aos posicionamentos de maior qualidade: Feed, Stories e Reels no
// Facebook e (quando há identidade de IG) no Instagram.
// Audience Network, Messenger, coluna direita, Marketplace e busca são excluídos
// — não convertem bem e queimam orçamento sem retorno.
const finalTargeting: Record<string, unknown> = { const finalTargeting: Record<string, unknown> = {
...baseTargeting, ...baseTargeting,
targeting_automation: { advantage_audience: 0 }, targeting_automation: { advantage_audience: 0 },
publisher_platforms: hasIg ? ['facebook', 'instagram'] : ['facebook'],
facebook_positions: ['feed', 'story', 'facebook_reels'],
...(hasIg ? { instagram_positions: ['stream', 'story', 'reels'] } : {}),
}; };
// Sem identidade de Instagram, o anúncio não pode rodar em posicionamentos
// do Instagram (a Meta recusa com subcode 1772103). Nesse caso restringimos
// aos posicionamentos que não exigem IG (Facebook/Audience Network/Messenger).
if (!resolvedInstagramActorId && !(baseTargeting as any).publisher_platforms) {
finalTargeting.publisher_platforms = ['facebook', 'audience_network', 'messenger'];
}
const result = await createFullCampaign(mcpClient, { const result = await createFullCampaign(mcpClient, {
campaign_name, campaign_name,
objective: finalObjective, objective: finalObjective,

View file

@ -1,6 +1,6 @@
import { NextRequest, NextResponse } from 'next/server'; import { NextRequest, NextResponse } from 'next/server';
import { createMetaMCPClient } from '@/lib/mcp/client'; import { createMetaMCPClient } from '@/lib/mcp/client';
import { getAccountInsights } from '@/lib/mcp/tools'; import { getAccountInsights, listCampaigns } from '@/lib/mcp/tools';
import { getAccessToken } from '@/lib/auth/session'; import { getAccessToken } from '@/lib/auth/session';
export async function GET(request: NextRequest) { export async function GET(request: NextRequest) {
@ -16,27 +16,38 @@ export async function GET(request: NextRequest) {
const client = await createMetaMCPClient(token); const client = await createMetaMCPClient(token);
try { try {
const rawOverview: any = await getAccountInsights(client, datePreset); const [rawOverview, campaignList] = await Promise.all([
getAccountInsights(client, datePreset) as Promise<any>,
listCampaigns(client, undefined, datePreset) as Promise<any>,
]);
// Calculate total leads from actions
const actions = rawOverview?.actions || []; const actions = rawOverview?.actions || [];
const totalLeads = actions.find((a: any) => a.action_type === 'lead')?.value || 0; const totalLeads = parseInt(actions.find((a: any) => a.action_type === 'lead' || a.action_type === 'onsite_conversion.lead_grouped')?.value || '0', 10);
const totalPurchases = parseInt(actions.find((a: any) => a.action_type === 'omni_purchase')?.value || '0', 10);
const purchaseValue = actions.find((a: any) => a.action_type === 'omni_purchase')?.value || 0;
const spend = parseFloat(rawOverview?.spend || '0'); const spend = parseFloat(rawOverview?.spend || '0');
const roas = spend > 0 && purchaseValue ? (parseFloat(purchaseValue) / spend) : 0; const purchaseRoasVal = rawOverview?.purchase_roas?.[0]?.value;
const roas = purchaseRoasVal ? parseFloat(purchaseRoasVal) : (spend > 0 && totalPurchases ? totalPurchases / spend : 0);
const avgCpa = totalPurchases > 0 ? spend / totalPurchases : 0;
const campaigns = Array.isArray(campaignList) ? campaignList : [];
const campaigns_active = campaigns.filter((c: any) => c.status === 'ACTIVE').length;
const campaigns_paused = campaigns.filter((c: any) => c.status === 'PAUSED').length;
// Map to expected AccountOverview structure
const overview = { const overview = {
total_spend: spend, total_spend: spend,
total_leads: parseInt(totalLeads, 10), total_leads: totalLeads,
total_purchases: totalPurchases,
avg_cpa: avgCpa,
total_clicks: parseInt(rawOverview?.clicks || '0', 10), total_clicks: parseInt(rawOverview?.clicks || '0', 10),
total_impressions: parseInt(rawOverview?.impressions || '0', 10), total_impressions: parseInt(rawOverview?.impressions || '0', 10),
total_reach: parseInt(rawOverview?.reach || '0', 10),
total_roas: roas, total_roas: roas,
avg_ctr: parseFloat(rawOverview?.ctr || '0'), avg_ctr: parseFloat(rawOverview?.ctr || '0'),
avg_cpc: parseFloat(rawOverview?.cpc || '0'), avg_cpc: parseFloat(rawOverview?.cpc || '0'),
avg_cpm: parseFloat(rawOverview?.cpm || '0'), avg_cpm: parseFloat(rawOverview?.cpm || '0'),
campaigns_active: 0, // Not available directly in insights, could fetch if needed campaigns_active,
campaigns_paused,
campaigns_total: campaigns.length,
}; };
return NextResponse.json({ success: true, data: overview }); return NextResponse.json({ success: true, data: overview });

View file

@ -8,17 +8,22 @@ function mapInsights(rawInsights: any) {
const item = rawInsights.data[0]; const item = rawInsights.data[0];
const actions = item.actions || []; const actions = item.actions || [];
const leads = actions.find((a: any) => a.action_type === 'lead' || a.action_type === 'onsite_conversion.lead_grouped')?.value || 0; const leadsVal = 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 purchaseVal = actions.find((a: any) => a.action_type === 'omni_purchase')?.value || 0;
const spendNum = parseFloat(item.spend || '0'); const spendNum = parseFloat(item.spend || '0');
const roas = spendNum > 0 && purchase ? parseFloat(purchase) / spendNum : 0; const purchases = parseInt(purchaseVal, 10);
const roas = spendNum > 0 && purchaseVal ? parseFloat(purchaseVal) / spendNum : 0;
const cpa = purchases > 0 ? spendNum / purchases : 0;
return { return {
spend: item.spend || '0', spend: item.spend || '0',
ctr: item.ctr || '0', ctr: item.ctr || '0',
cpc: item.cpc || '0', cpc: item.cpc || '0',
cpm: item.cpm || '0', cpm: item.cpm || '0',
leads: parseInt(leads, 10), reach: parseInt(item.reach || '0', 10),
leads: parseInt(leadsVal, 10),
purchases,
cpa,
roas, roas,
}; };
} }

View file

@ -192,18 +192,46 @@ export default function AnalysisPage() {
{report && !analyzing && ( {report && !analyzing && (
<div className="grid gap-6 animate-fade-in-up delay-2"> <div className="grid gap-6 animate-fade-in-up delay-2">
{/* Métricas Hero */} {/* Métricas Hero */}
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4"> <div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 gap-3">
<div className="glass-card-static p-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-xs text-white/50 uppercase tracking-widest font-semibold mb-1">Anúncios</p>
<p className="text-2xl font-bold text-white">{report.ads_count}</p> <p className="text-2xl font-bold text-white">{report.ads_count}</p>
<p className="text-xs text-white/40 mt-1">{report.campaign_metrics.objective_label || '—'}</p>
</div> </div>
<div className="glass-card-static p-4"> <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-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> <p className="text-2xl font-bold text-white">R$ {parseFloat(report.campaign_metrics.spend).toFixed(2)}</p>
<p className="text-xs text-white/40 mt-1">{Number(report.campaign_metrics.impressions || 0).toLocaleString('pt-BR')} impressões</p>
</div> </div>
{report.campaign_metrics.leads > 0 && (
<div className="glass-card-static p-4">
<p className="text-xs text-white/50 uppercase tracking-widest font-semibold mb-1">Leads</p>
<p className="text-2xl font-bold text-[#4ade80]">{report.campaign_metrics.leads}</p>
{report.campaign_metrics.cpl && (
<p className="text-xs text-white/40 mt-1">CPL: R$ {parseFloat(report.campaign_metrics.cpl).toFixed(2)}</p>
)}
</div>
)}
{report.campaign_metrics.purchases > 0 && (
<div className="glass-card-static p-4">
<p className="text-xs text-white/50 uppercase tracking-widest font-semibold mb-1">Compras</p>
<p className="text-2xl font-bold text-[#4ade80]">{report.campaign_metrics.purchases}</p>
{report.campaign_metrics.cpa && (
<p className="text-xs text-white/40 mt-1">CPA: R$ {parseFloat(report.campaign_metrics.cpa).toFixed(2)}</p>
)}
</div>
)}
{report.campaign_metrics.roas && (
<div className="glass-card-static p-4"> <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-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> <p className="text-2xl font-bold text-[#4ade80]">{parseFloat(report.campaign_metrics.roas).toFixed(2)}x</p>
<p className="text-xs text-white/40 mt-1">Retorno sobre investimento</p>
</div>
)}
<div className="glass-card-static p-4">
<p className="text-xs text-white/50 uppercase tracking-widest font-semibold mb-1">CTR</p>
<p className="text-2xl font-bold text-white">{parseFloat(report.campaign_metrics.ctr || '0').toFixed(2)}%</p>
<p className="text-xs text-white/40 mt-1">CPC: R$ {parseFloat(report.campaign_metrics.cpc || '0').toFixed(2)}</p>
</div> </div>
</div> </div>
@ -228,9 +256,15 @@ export default function AnalysisPage() {
)} )}
<div className="p-4 flex-1 flex flex-col text-sm"> <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> <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"> <div className="flex justify-between items-center text-xs text-white/50 mb-1">
<span>Gasto: R$ {parseFloat(ad.spend).toFixed(2)}</span> <span>Gasto: R$ {parseFloat(ad.spend).toFixed(2)}</span>
<span className="text-[#4ade80]">ROAS: {parseFloat(ad.roas).toFixed(2)}x</span> <span>CTR: {ad.ctr}</span>
</div>
<div className="flex justify-between items-center text-xs mb-2">
{ad.leads > 0 && <span className="text-[#4ade80]">Leads: {ad.leads} {ad.cpl ? `· CPL: ${ad.cpl}` : ''}</span>}
{ad.purchases > 0 && <span className="text-[#4ade80]">Compras: {ad.purchases} {ad.cpa ? `· CPA: ${ad.cpa}` : ''}</span>}
{ad.roas && <span className="text-[#4ade80]">ROAS: {ad.roas}</span>}
{!ad.leads && !ad.purchases && !ad.roas && <span className="text-white/30">CPC: {ad.cpc}</span>}
</div> </div>
<p className="text-xs text-white/60 line-clamp-3 mt-auto">{ad.body || 'Sem texto'}</p> <p className="text-xs text-white/60 line-clamp-3 mt-auto">{ad.body || 'Sem texto'}</p>
</div> </div>

View file

@ -130,13 +130,13 @@ export default function DashboardPage() {
{/* Metrics Cards */} {/* Metrics Cards */}
{loading ? ( {loading ? (
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-4"> <div className="grid grid-cols-2 md:grid-cols-4 gap-4">
{Array.from({ length: 6 }).map((_, i) => ( {Array.from({ length: 8 }).map((_, i) => (
<div key={i} className="shimmer h-[120px]" /> <div key={i} className="shimmer h-[120px]" />
))} ))}
</div> </div>
) : overview ? ( ) : overview ? (
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-4"> <div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<MetricsCard <MetricsCard
title="Investido" title="Investido"
value={formatCurrency(overview.total_spend)} value={formatCurrency(overview.total_spend)}
@ -146,10 +146,10 @@ export default function DashboardPage() {
delay={1} delay={1}
/> />
<MetricsCard <MetricsCard
title="Leads" title="Compras"
value={formatNumber(overview.total_leads)} value={formatNumber(overview.total_purchases)}
subtitle={overview.total_leads > 0 ? `CPL: ${formatCurrency(overview.total_spend / overview.total_leads)}` : undefined} subtitle={overview.avg_cpa > 0 ? `CPA: ${formatCurrency(overview.avg_cpa)}` : 'Custo por compra'}
icon="users" icon="trending"
color="green" color="green"
delay={2} delay={2}
/> />
@ -161,29 +161,45 @@ export default function DashboardPage() {
color="blue" color="blue"
delay={3} delay={3}
/> />
<MetricsCard
title="Leads"
value={formatNumber(overview.total_leads)}
subtitle={overview.total_leads > 0 ? `CPL: ${formatCurrency(overview.total_spend / overview.total_leads)}` : 'Custo por lead'}
icon="users"
color="amber"
delay={4}
/>
<MetricsCard
title="Alcance"
value={formatNumber(overview.total_reach)}
subtitle={`${formatNumber(overview.total_impressions)} impressões`}
icon="chart"
color="cyan"
delay={5}
/>
<MetricsCard <MetricsCard
title="CTR" title="CTR"
value={`${overview.avg_ctr.toFixed(2)}%`} value={`${overview.avg_ctr.toFixed(2)}%`}
subtitle={`${formatNumber(overview.total_clicks)} cliques`} subtitle={`${formatNumber(overview.total_clicks)} cliques`}
icon="target" icon="target"
color="amber" color="pink"
delay={4} delay={6}
/> />
<MetricsCard <MetricsCard
title="CPC" title="CPC"
value={formatCurrency(overview.avg_cpc)} value={formatCurrency(overview.avg_cpc)}
subtitle="Custo por clique" subtitle="Custo por clique"
icon="click" icon="click"
color="pink" color="purple"
delay={5} delay={7}
/> />
<MetricsCard <MetricsCard
title="CPM" title="CPM"
value={formatCurrency(overview.avg_cpm)} value={formatCurrency(overview.avg_cpm)}
subtitle={`${formatNumber(overview.total_impressions)} impressões`} subtitle="Custo por mil impressões"
icon="chart" icon="chart"
color="cyan" color="amber"
delay={6} delay={8}
/> />
</div> </div>
) : null} ) : null}

View file

@ -1,7 +1,7 @@
'use client'; 'use client';
import React, { useState } from 'react'; import React, { useState } from 'react';
import { Pause, Play, DollarSign, Search, ArrowUpDown, ChevronDown, ChevronRight, Folder, Layers, Megaphone, Plus, Copy } from 'lucide-react'; import { Pause, Play, DollarSign, Search, ArrowUpDown, ChevronDown, ChevronRight, Folder, Layers, Megaphone, Plus, Copy, Target } from 'lucide-react';
import { Spinner } from '@/components/ui/Spinner'; import { Spinner } from '@/components/ui/Spinner';
import { BudgetModal } from './BudgetModal'; import { BudgetModal } from './BudgetModal';
import { AddAdModal } from './AddAdModal'; import { AddAdModal } from './AddAdModal';
@ -15,17 +15,22 @@ interface CampaignTableProps {
onAdCreated: () => void; onAdCreated: () => void;
} }
type SortField = 'name' | 'status' | 'spend' | 'ctr' | 'cpc' | 'cpm' | 'leads' | 'roas'; type SortField = 'name' | 'status' | 'spend' | 'ctr' | 'cpc' | 'cpm' | 'leads' | 'purchases' | 'roas';
function getInsights(c: CampaignWithInsights) {
return c.insights as any;
}
function getLeads(c: CampaignWithInsights): number { function getLeads(c: CampaignWithInsights): number {
const a = c.insights?.actions?.find( return getInsights(c)?.leads || 0;
(x) => x.action_type === 'lead' || x.action_type === 'onsite_conversion.lead_grouped' }
);
return a ? parseInt(a.value, 10) : 0; function getPurchases(c: CampaignWithInsights): number {
return getInsights(c)?.purchases || 0;
} }
function getRoas(c: CampaignWithInsights): number { function getRoas(c: CampaignWithInsights): number {
return c.insights?.purchase_roas?.[0] ? parseFloat(c.insights.purchase_roas[0].value) : 0; return getInsights(c)?.roas || 0;
} }
export function CampaignTable({ campaigns, loading, onStatusChange, onBudgetChange, onAdCreated }: CampaignTableProps) { export function CampaignTable({ campaigns, loading, onStatusChange, onBudgetChange, onAdCreated }: CampaignTableProps) {
@ -43,10 +48,30 @@ export function CampaignTable({ campaigns, loading, onStatusChange, onBudgetChan
const [duplicatingId, setDuplicatingId] = useState<string | null>(null); const [duplicatingId, setDuplicatingId] = useState<string | null>(null);
const [dupError, setDupError] = useState<string | null>(null); const [dupError, setDupError] = useState<string | null>(null);
const [fixingPlacementsId, setFixingPlacementsId] = useState<string | null>(null);
const [placementsMsg, setPlacementsMsg] = useState<{ type: 'ok' | 'err'; text: string } | null>(null);
// State for tracking which rows (Campaign or AdSet) are expanded // State for tracking which rows (Campaign or AdSet) are expanded
const [expandedRows, setExpandedRows] = useState<Set<string>>(new Set()); const [expandedRows, setExpandedRows] = useState<Set<string>>(new Set());
const fixPlacements = async (adsetId: string) => {
setFixingPlacementsId(adsetId);
setPlacementsMsg(null);
try {
const res = await fetch(`/api/adsets/${adsetId}/placements`, { method: 'PATCH' });
const data = await res.json();
if (!data.success) {
setPlacementsMsg({ type: 'err', text: data.error || 'Erro ao corrigir posicionamentos.' });
} else {
setPlacementsMsg({ type: 'ok', text: 'Posicionamentos corrigidos! Só Feed, Stories e Reels.' });
}
} catch (err) {
setPlacementsMsg({ type: 'err', text: 'Erro ao conectar com o servidor.' });
} finally {
setFixingPlacementsId(null);
}
};
const duplicate = async (level: 'campaign' | 'adset' | 'ad', id: string) => { const duplicate = async (level: 'campaign' | 'adset' | 'ad', id: string) => {
setDuplicatingId(id); setDuplicatingId(id);
setDupError(null); setDupError(null);
@ -100,6 +125,7 @@ export function CampaignTable({ campaigns, loading, onStatusChange, onBudgetChan
case 'cpc': return parseFloat(a.insights?.cpc || '0') - parseFloat(b.insights?.cpc || '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 'cpm': return parseFloat(a.insights?.cpm || '0') - parseFloat(b.insights?.cpm || '0');
case 'leads': return getLeads(a) - getLeads(b); case 'leads': return getLeads(a) - getLeads(b);
case 'purchases': return getPurchases(a) - getPurchases(b);
case 'roas': return getRoas(a) - getRoas(b); case 'roas': return getRoas(a) - getRoas(b);
default: return 0; default: return 0;
} }
@ -138,6 +164,12 @@ export function CampaignTable({ campaigns, loading, onStatusChange, onBudgetChan
<button onClick={() => setDupError(null)} className="shrink-0 hover:text-white transition-colors"></button> <button onClick={() => setDupError(null)} className="shrink-0 hover:text-white transition-colors"></button>
</div> </div>
)} )}
{placementsMsg && (
<div className="mx-5 mt-3 px-4 py-2.5 rounded-lg text-xs flex items-center justify-between gap-3" style={{ background: placementsMsg.type === 'ok' ? 'rgba(34,197,94,0.08)' : 'rgba(239,68,68,0.08)', border: `1px solid ${placementsMsg.type === 'ok' ? 'rgba(34,197,94,0.15)' : 'rgba(239,68,68,0.15)'}`, color: placementsMsg.type === 'ok' ? '#4ade80' : '#f87171' }}>
<span>{placementsMsg.text}</span>
<button onClick={() => setPlacementsMsg(null)} className="shrink-0 hover:text-white transition-colors"></button>
</div>
)}
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<table className="w-full campaign-table"> <table className="w-full campaign-table">
<thead> <thead>
@ -150,15 +182,17 @@ export function CampaignTable({ campaigns, loading, onStatusChange, onBudgetChan
<th><SortBtn field="cpc" label="CPC" /></th> <th><SortBtn field="cpc" label="CPC" /></th>
<th><SortBtn field="cpm" label="CPM" /></th> <th><SortBtn field="cpm" label="CPM" /></th>
<th><SortBtn field="leads" label="Leads" /></th> <th><SortBtn field="leads" label="Leads" /></th>
<th><SortBtn field="purchases" label="Compras" /></th>
<th><SortBtn field="roas" label="ROAS" /></th> <th><SortBtn field="roas" label="ROAS" /></th>
<th className="pr-5">Ações</th> <th className="pr-5">Ações</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{sorted.length === 0 ? ( {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> <tr><td colSpan={11} className="text-center py-12"><p className="text-[var(--muted-fg)]">{search ? 'Nenhuma campanha encontrada' : 'Nenhuma campanha ativa'}</p></td></tr>
) : sorted.map(c => { ) : sorted.map(c => {
const leads = getLeads(c); const leads = getLeads(c);
const purchases = getPurchases(c);
const roas = getRoas(c); const roas = getRoas(c);
const active = c.campaign.status === 'ACTIVE'; const active = c.campaign.status === 'ACTIVE';
const isLoading = actionLoading === c.campaign.id; const isLoading = actionLoading === c.campaign.id;
@ -197,7 +231,8 @@ export function CampaignTable({ campaigns, loading, onStatusChange, onBudgetChan
<td className="text-sm text-[var(--muted-fg)]">{parseFloat(c.insights?.ctr || '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?.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 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: leads > 0 ? '#4ade80' : 'var(--muted-fg)' }}>{leads > 0 ? leads : '—'}</td>
<td className="text-sm" style={{ color: purchases > 0 ? '#4ade80' : 'var(--muted-fg)' }}>{purchases > 0 ? purchases : '—'}</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="text-sm" style={{ color: roas >= 1 ? '#4ade80' : roas > 0 ? '#fbbf24' : 'var(--muted-fg)' }}>{roas > 0 ? `${roas.toFixed(2)}x` : '—'}</td>
<td className="pr-5"> <td className="pr-5">
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
@ -250,7 +285,8 @@ export function CampaignTable({ campaigns, loading, onStatusChange, onBudgetChan
<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">{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?.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 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?.leads || 0) > 0 ? '#4ade80' : 'var(--muted-fg)' }}>{(adset.insights?.leads || 0) > 0 ? adset.insights?.leads : '—'}</td>
<td className="text-xs py-2" style={{ color: ((adset.insights as any)?.purchases || 0) > 0 ? '#4ade80' : 'var(--muted-fg)' }}>{((adset.insights as any)?.purchases || 0) > 0 ? (adset.insights as any).purchases : '—'}</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="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"> <td className="pr-5 py-2">
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
@ -269,6 +305,14 @@ export function CampaignTable({ campaigns, loading, onStatusChange, onBudgetChan
<button onClick={() => duplicate('adset', adset.id)} disabled={duplicatingId === adset.id} className="p-1 rounded hover:bg-white/5 transition-colors text-[var(--muted-fg)] hover:text-white" title="Duplicar conjunto (com os anúncios, Pausado)"> <button onClick={() => duplicate('adset', adset.id)} disabled={duplicatingId === adset.id} className="p-1 rounded hover:bg-white/5 transition-colors text-[var(--muted-fg)] hover:text-white" title="Duplicar conjunto (com os anúncios, Pausado)">
{duplicatingId === adset.id ? <Spinner size="sm" /> : <Copy size={12} />} {duplicatingId === adset.id ? <Spinner size="sm" /> : <Copy size={12} />}
</button> </button>
<button
onClick={() => fixPlacements(adset.id)}
disabled={fixingPlacementsId === adset.id}
className="p-1 rounded hover:bg-white/5 transition-colors text-[var(--muted-fg)] hover:text-amber-400"
title="Corrigir posicionamentos (Feed + Stories + Reels apenas)"
>
{fixingPlacementsId === adset.id ? <Spinner size="sm" /> : <Target size={12} />}
</button>
<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'}> <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} />} {actionLoading === adset.id ? <Spinner size="sm" /> : adsetActive ? <Pause size={12} /> : <Play size={12} />}
</button> </button>
@ -297,7 +341,8 @@ export function CampaignTable({ campaigns, loading, onStatusChange, onBudgetChan
<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">{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?.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 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?.leads || 0) > 0 ? '#4ade80' : 'var(--muted-fg)' }}>{(ad.insights?.leads || 0) > 0 ? ad.insights?.leads : '—'}</td>
<td className="text-xs py-1.5" style={{ color: ((ad.insights as any)?.purchases || 0) > 0 ? '#4ade80' : 'var(--muted-fg)' }}>{((ad.insights as any)?.purchases || 0) > 0 ? (ad.insights as any).purchases : '—'}</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="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"> <td className="pr-5 py-1.5">
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">

View file

@ -28,22 +28,20 @@ export async function analyzePerformance(
const client = getAIClient(); const client = getAIClient();
const campaignSummary = campaigns.map((c) => { const campaignSummary = campaigns.map((c) => {
const leads = c.insights?.actions?.find( const ins = c.insights as any;
(a) => a.action_type === 'lead' || a.action_type === 'onsite_conversion.lead_grouped'
);
const roas = c.insights?.purchase_roas?.[0];
return { return {
id: c.campaign.id, id: c.campaign.id,
name: c.campaign.name, name: c.campaign.name,
status: c.campaign.status, status: c.campaign.status,
objective: c.campaign.objective, objective: c.campaign.objective,
daily_budget: `R$${(parseInt(c.campaign.daily_budget || '0', 10) / 100).toFixed(2)}`, daily_budget: `R$${(parseInt(c.campaign.daily_budget || '0', 10) / 100).toFixed(2)}`,
spend: `R$${parseFloat(c.insights?.spend || '0').toFixed(2)}`, spend: `R$${parseFloat(ins?.spend || '0').toFixed(2)}`,
ctr: `${parseFloat(c.insights?.ctr || '0').toFixed(2)}%`, ctr: `${parseFloat(ins?.ctr || '0').toFixed(2)}%`,
cpc: `R$${parseFloat(c.insights?.cpc || '0').toFixed(2)}`, cpc: `R$${parseFloat(ins?.cpc || '0').toFixed(2)}`,
cpm: `R$${parseFloat(c.insights?.cpm || '0').toFixed(2)}`, cpm: `R$${parseFloat(ins?.cpm || '0').toFixed(2)}`,
leads: leads ? parseInt(leads.value, 10) : 0, leads: ins?.leads || 0,
roas: roas ? parseFloat(roas.value).toFixed(2) : '0', purchases: ins?.purchases || 0,
roas: ins?.roas ? ins.roas.toFixed(2) : '0',
}; };
}); });

View file

@ -129,7 +129,7 @@ export async function callMetaTool(
switch (toolName) { switch (toolName) {
case 'list_campaigns': { case 'list_campaigns': {
const preset = args.date_preset || 'last_30d'; const preset = args.date_preset || 'last_30d';
const ins = `insights.date_preset(${preset}){spend,ctr,cpc,cpm,actions,purchase_roas}`; const ins = `insights.date_preset(${preset}){spend,ctr,cpc,cpm,reach,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}}}`; 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 }); resultData = await fetchGraphGet(`/${accountId}/campaigns`, token, { fields });
return { content: [{ type: 'text', text: JSON.stringify(resultData.data || []) }] }; return { content: [{ type: 'text', text: JSON.stringify(resultData.data || []) }] };
@ -220,7 +220,7 @@ export async function callMetaTool(
case 'get_campaign_details': { case 'get_campaign_details': {
requireFields(args, ['campaign_id'], toolName); requireFields(args, ['campaign_id'], toolName);
const preset = args.date_preset || 'last_30d'; const preset = args.date_preset || 'last_30d';
const ins = `insights.date_preset(${preset}){spend,ctr,cpc,cpm,actions,purchase_roas}`; const ins = `insights.date_preset(${preset}){spend,ctr,cpc,cpm,reach,impressions,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}}}`; 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 }); resultData = await fetchGraphGet(`/${args.campaign_id}`, token, { fields });
return { content: [{ type: 'text', text: JSON.stringify(resultData) }] }; return { content: [{ type: 'text', text: JSON.stringify(resultData) }] };
@ -234,12 +234,21 @@ export async function callMetaTool(
resultData = await fetchGraphPostForm(`/${args.campaign_id}`, token, updateCampPayload); resultData = await fetchGraphPostForm(`/${args.campaign_id}`, token, updateCampPayload);
return { content: [{ type: 'text', text: JSON.stringify(resultData) }] }; return { content: [{ type: 'text', text: JSON.stringify(resultData) }] };
case 'get_adset': {
requireFields(args, ['adset_id'], toolName);
resultData = await fetchGraphGet(`/${args.adset_id}`, token, {
fields: 'id,name,status,targeting,daily_budget',
});
return { content: [{ type: 'text', text: JSON.stringify(resultData) }] };
}
case 'update_adset': case 'update_adset':
requireFields(args, ['adset_id'], toolName); requireFields(args, ['adset_id'], toolName);
const updateAdsetPayload: any = {}; const updateAdsetPayload: any = {};
if (args.status) updateAdsetPayload.status = args.status; if (args.status) updateAdsetPayload.status = args.status;
if (args.daily_budget) updateAdsetPayload.daily_budget = args.daily_budget; if (args.daily_budget) updateAdsetPayload.daily_budget = args.daily_budget;
if (args.bid_amount) updateAdsetPayload.bid_amount = args.bid_amount; if (args.bid_amount) updateAdsetPayload.bid_amount = args.bid_amount;
if (args.targeting) updateAdsetPayload.targeting = args.targeting;
resultData = await fetchGraphPostForm(`/${args.adset_id}`, token, updateAdsetPayload); resultData = await fetchGraphPostForm(`/${args.adset_id}`, token, updateAdsetPayload);
return { content: [{ type: 'text', text: JSON.stringify(resultData) }] }; return { content: [{ type: 'text', text: JSON.stringify(resultData) }] };

View file

@ -308,6 +308,51 @@ export async function updateAdSetBudget(
return extractContent(result); return extractContent(result);
} }
/**
* Busca o targeting atual do ad set para preservar geo/idade/interesses
* ao aplicar restrições de posicionamento.
*/
async function getAdSetTargeting(client: Client, adSetId: string): Promise<Record<string, unknown>> {
const result = await callMetaTool(client, 'get_adset', { adset_id: adSetId });
const data = extractContent(result) as any;
return data?.targeting || {};
}
/**
* Atualiza os posicionamentos de um ad set existente para o conjunto curado
* (Feed + Stories + Reels no FB e IG quando disponível). Preserva geo, idade
* e interesses substitui publisher_platforms e as posições.
*/
export async function updateAdSetPlacements(client: Client, adSetId: string): Promise<void> {
requireId(adSetId, 'adSetId');
const [defaults, currentTargeting] = await Promise.all([
getAdSetDefaultCreative(client, adSetId),
getAdSetTargeting(client, adSetId),
]);
const hasIg = !!defaults.instagram_actor_id;
// Remove campos de posicionamento antigos antes de sobrescrever
const {
publisher_platforms: _pp,
facebook_positions: _fp,
instagram_positions: _ip,
audience_network_positions: _anp,
messenger_positions: _mp,
...baseTargeting
} = currentTargeting as any;
const newTargeting = {
...baseTargeting,
publisher_platforms: hasIg ? ['facebook', 'instagram'] : ['facebook'],
facebook_positions: ['feed', 'story', 'facebook_reels'],
...(hasIg ? { instagram_positions: ['stream', 'story', 'reels'] } : {}),
};
await callMetaTool(client, 'update_adset', { adset_id: adSetId, targeting: newTargeting });
}
export async function pauseAdSet(client: Client, adSetId: string) { export async function pauseAdSet(client: Client, adSetId: string) {
requireId(adSetId, 'adSetId'); requireId(adSetId, 'adSetId');

View file

@ -2,18 +2,23 @@
// Meta Marketing API — TypeScript Types // Meta Marketing API — TypeScript Types
// ============================================ // ============================================
export interface AdInsights {
spend: string;
ctr: string;
cpc: string;
cpm: string;
reach: number;
leads: number;
purchases: number;
cpa: number;
roas: number;
}
export interface Ad { export interface Ad {
id: string; id: string;
name: string; name: string;
status: 'ACTIVE' | 'PAUSED' | 'DELETED' | 'ARCHIVED'; status: 'ACTIVE' | 'PAUSED' | 'DELETED' | 'ARCHIVED';
insights?: { insights?: AdInsights;
spend: string;
ctr: string;
cpc: string;
cpm: string;
leads: number;
roas: number;
};
} }
export interface AdSet { export interface AdSet {
@ -24,14 +29,7 @@ export interface AdSet {
ads?: { ads?: {
data: Ad[]; data: Ad[];
}; };
insights?: { insights?: AdInsights;
spend: string;
ctr: string;
cpc: string;
cpm: string;
leads: number;
roas: number;
};
} }
export interface Campaign { export interface Campaign {
@ -91,6 +89,8 @@ export interface AccountOverview {
total_clicks: number; total_clicks: number;
total_reach: number; total_reach: number;
total_leads: number; total_leads: number;
total_purchases: number;
avg_cpa: number;
avg_ctr: number; avg_ctr: number;
avg_cpc: number; avg_cpc: number;
avg_cpm: number; avg_cpm: number;