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:
parent
3ac5086161
commit
4bde1cc5c8
12 changed files with 407 additions and 138 deletions
32
src/app/api/adsets/[id]/placements/route.ts
Normal file
32
src/app/api/adsets/[id]/placements/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -6,6 +6,24 @@ import { getAIClient } from '@/lib/ai/client';
|
|||
|
||||
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) {
|
||||
try {
|
||||
const token = await getAccessToken();
|
||||
|
|
@ -20,7 +38,7 @@ export async function POST(request: NextRequest) {
|
|||
|
||||
const client = await createMetaMCPClient(token);
|
||||
let campaignData: any;
|
||||
|
||||
|
||||
try {
|
||||
const result = await getCampaignDetails(client, campaignId);
|
||||
campaignData = Array.isArray(result) ? result[0] : result;
|
||||
|
|
@ -32,108 +50,162 @@ export async function POST(request: NextRequest) {
|
|||
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');
|
||||
|
||||
const actions = insights.actions || [];
|
||||
const objective = campaignData.objective || '';
|
||||
|
||||
const spend = parseFloat(insights.spend || '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
|
||||
const adsToAnalyze = [];
|
||||
const adsToAnalyze: any[] = [];
|
||||
for (const adset of adsets) {
|
||||
const ads = adset.ads?.data || [];
|
||||
for (const ad of ads) {
|
||||
const adInsights = ad.insights?.data?.[0] || {};
|
||||
for (const ad of adset.ads?.data || []) {
|
||||
const adIns = ad.insights?.data?.[0] || {};
|
||||
const adActions = adIns.actions || [];
|
||||
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.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) {
|
||||
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
|
||||
body = body || feed.bodies?.map((b: any) => b.text).join(' | ') || '';
|
||||
title = title || feed.titles?.map((t: any) => t.text).join(' | ') || '';
|
||||
if (!imageUrl && feed.images?.length > 0) {
|
||||
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({
|
||||
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,
|
||||
spend: adSpend.toFixed(2),
|
||||
ctr: parseFloat(adIns.ctr || '0').toFixed(2) + '%',
|
||||
cpc: 'R$ ' + parseFloat(adIns.cpc || '0').toFixed(2),
|
||||
leads: adLeads,
|
||||
purchases: adPurchases,
|
||||
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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 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 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.
|
||||
const prompt = `Você é um Especialista Sênior em Tráfego Pago e Copywriting com foco no mercado brasileiro.
|
||||
Faça uma auditoria completa e honesta desta campanha no Meta Ads.
|
||||
|
||||
${focusInstructions}
|
||||
|
||||
DADOS DA CAMPANHA:
|
||||
Nome: ${campaignData.name}
|
||||
Gasto Total (30d): R$ ${totalSpend}
|
||||
ROAS Total: ${roas}
|
||||
${metricsBlock}
|
||||
|
||||
ANÚNCIOS ATIVOS (CRIATIVOS E MÉTRICAS):
|
||||
ANÚNCIOS (CRIATIVOS E MÉTRICAS INDIVIDUAIS):
|
||||
${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).
|
||||
Retorne SOMENTE um JSON válido, sem markdown, com esta estrutura exata:
|
||||
{
|
||||
"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)..."],
|
||||
"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 sobre texto do anúncio X...", "Sugestão específica de melhoria..."],
|
||||
"analise_imagem": ["Avaliação do visual...", "Sugestão de formato ou abordagem..."],
|
||||
"analise_metricas": ["Métrica X está Y — o que significa...", "Comparativo com benchmark..."],
|
||||
"acao_recomendada": ["Ação imediata 1...", "Ação 2..."],
|
||||
"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",
|
||||
"nicho_sugerido": "ecommerce / info_produto / negocios_locais / etc",
|
||||
"produto_foco": "Produto ou oferta que deve ser o foco",
|
||||
"publico_alvo": "Público ideal baseado nos dados",
|
||||
"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;
|
||||
let jsonRes: any;
|
||||
|
||||
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);
|
||||
const cleaned = textRes.replace(/^```json/m, '').replace(/```$/m, '').trim();
|
||||
jsonRes = JSON.parse(cleaned);
|
||||
} catch {
|
||||
console.error('[Analysis] 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 },
|
||||
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: adsToAnalyze,
|
||||
analysis: jsonRes
|
||||
}
|
||||
analysis: jsonRes,
|
||||
},
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('[Analysis] API Error:', error);
|
||||
return NextResponse.json(
|
||||
|
|
|
|||
|
|
@ -257,18 +257,20 @@ export async function POST(req: Request) {
|
|||
});
|
||||
|
||||
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> = {
|
||||
...baseTargeting,
|
||||
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, {
|
||||
campaign_name,
|
||||
objective: finalObjective,
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { NextRequest, NextResponse } from 'next/server';
|
||||
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';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
|
|
@ -16,27 +16,38 @@ export async function GET(request: NextRequest) {
|
|||
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;
|
||||
const [rawOverview, campaignList] = await Promise.all([
|
||||
getAccountInsights(client, datePreset) as Promise<any>,
|
||||
listCampaigns(client, undefined, datePreset) as Promise<any>,
|
||||
]);
|
||||
|
||||
const actions = rawOverview?.actions || [];
|
||||
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 spend = parseFloat(rawOverview?.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 = {
|
||||
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_impressions: parseInt(rawOverview?.impressions || '0', 10),
|
||||
total_reach: parseInt(rawOverview?.reach || '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
|
||||
campaigns_active,
|
||||
campaigns_paused,
|
||||
campaigns_total: campaigns.length,
|
||||
};
|
||||
|
||||
return NextResponse.json({ success: true, data: overview });
|
||||
|
|
|
|||
|
|
@ -6,19 +6,24 @@ 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 leadsVal = actions.find((a: any) => a.action_type === 'lead' || a.action_type === 'onsite_conversion.lead_grouped')?.value || 0;
|
||||
const purchaseVal = 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;
|
||||
const purchases = parseInt(purchaseVal, 10);
|
||||
const roas = spendNum > 0 && purchaseVal ? parseFloat(purchaseVal) / spendNum : 0;
|
||||
const cpa = purchases > 0 ? spendNum / purchases : 0;
|
||||
|
||||
return {
|
||||
spend: item.spend || '0',
|
||||
ctr: item.ctr || '0',
|
||||
cpc: item.cpc || '0',
|
||||
cpm: item.cpm || '0',
|
||||
leads: parseInt(leads, 10),
|
||||
reach: parseInt(item.reach || '0', 10),
|
||||
leads: parseInt(leadsVal, 10),
|
||||
purchases,
|
||||
cpa,
|
||||
roas,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -192,18 +192,46 @@ export default function AnalysisPage() {
|
|||
{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="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 gap-3">
|
||||
<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-xs text-white/40 mt-1">{report.campaign_metrics.objective_label || '—'}</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>
|
||||
<p className="text-xs text-white/40 mt-1">{Number(report.campaign_metrics.impressions || 0).toLocaleString('pt-BR')} impressões</p>
|
||||
</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">
|
||||
<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-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">ROAS</p>
|
||||
<p className="text-2xl font-bold text-[#4ade80]">{parseFloat(report.campaign_metrics.roas).toFixed(2)}x</p>
|
||||
<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>
|
||||
|
||||
|
|
@ -228,9 +256,15 @@ export default function AnalysisPage() {
|
|||
)}
|
||||
<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">
|
||||
<div className="flex justify-between items-center text-xs text-white/50 mb-1">
|
||||
<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>
|
||||
<p className="text-xs text-white/60 line-clamp-3 mt-auto">{ad.body || 'Sem texto'}</p>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -130,13 +130,13 @@ export default function DashboardPage() {
|
|||
|
||||
{/* 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 className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
{Array.from({ length: 8 }).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">
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<MetricsCard
|
||||
title="Investido"
|
||||
value={formatCurrency(overview.total_spend)}
|
||||
|
|
@ -146,10 +146,10 @@ export default function DashboardPage() {
|
|||
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"
|
||||
title="Compras"
|
||||
value={formatNumber(overview.total_purchases)}
|
||||
subtitle={overview.avg_cpa > 0 ? `CPA: ${formatCurrency(overview.avg_cpa)}` : 'Custo por compra'}
|
||||
icon="trending"
|
||||
color="green"
|
||||
delay={2}
|
||||
/>
|
||||
|
|
@ -161,29 +161,45 @@ export default function DashboardPage() {
|
|||
color="blue"
|
||||
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
|
||||
title="CTR"
|
||||
value={`${overview.avg_ctr.toFixed(2)}%`}
|
||||
subtitle={`${formatNumber(overview.total_clicks)} cliques`}
|
||||
icon="target"
|
||||
color="amber"
|
||||
delay={4}
|
||||
color="pink"
|
||||
delay={6}
|
||||
/>
|
||||
<MetricsCard
|
||||
title="CPC"
|
||||
value={formatCurrency(overview.avg_cpc)}
|
||||
subtitle="Custo por clique"
|
||||
icon="click"
|
||||
color="pink"
|
||||
delay={5}
|
||||
color="purple"
|
||||
delay={7}
|
||||
/>
|
||||
<MetricsCard
|
||||
title="CPM"
|
||||
value={formatCurrency(overview.avg_cpm)}
|
||||
subtitle={`${formatNumber(overview.total_impressions)} impressões`}
|
||||
subtitle="Custo por mil impressões"
|
||||
icon="chart"
|
||||
color="cyan"
|
||||
delay={6}
|
||||
color="amber"
|
||||
delay={8}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
'use client';
|
||||
|
||||
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 { BudgetModal } from './BudgetModal';
|
||||
import { AddAdModal } from './AddAdModal';
|
||||
|
|
@ -15,17 +15,22 @@ interface CampaignTableProps {
|
|||
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 {
|
||||
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;
|
||||
return getInsights(c)?.leads || 0;
|
||||
}
|
||||
|
||||
function getPurchases(c: CampaignWithInsights): number {
|
||||
return getInsights(c)?.purchases || 0;
|
||||
}
|
||||
|
||||
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) {
|
||||
|
|
@ -43,10 +48,30 @@ export function CampaignTable({ campaigns, loading, onStatusChange, onBudgetChan
|
|||
|
||||
const [duplicatingId, setDuplicatingId] = 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
|
||||
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) => {
|
||||
setDuplicatingId(id);
|
||||
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 'cpm': return parseFloat(a.insights?.cpm || '0') - parseFloat(b.insights?.cpm || '0');
|
||||
case 'leads': return getLeads(a) - getLeads(b);
|
||||
case 'purchases': return getPurchases(a) - getPurchases(b);
|
||||
case 'roas': return getRoas(a) - getRoas(b);
|
||||
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>
|
||||
</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">
|
||||
<table className="w-full campaign-table">
|
||||
<thead>
|
||||
|
|
@ -150,15 +182,17 @@ export function CampaignTable({ campaigns, loading, onStatusChange, onBudgetChan
|
|||
<th><SortBtn field="cpc" label="CPC" /></th>
|
||||
<th><SortBtn field="cpm" label="CPM" /></th>
|
||||
<th><SortBtn field="leads" label="Leads" /></th>
|
||||
<th><SortBtn field="purchases" label="Compras" /></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>
|
||||
<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 => {
|
||||
const leads = getLeads(c);
|
||||
const purchases = getPurchases(c);
|
||||
const roas = getRoas(c);
|
||||
const active = c.campaign.status === 'ACTIVE';
|
||||
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)]">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: 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="pr-5">
|
||||
<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">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?.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="pr-5 py-2">
|
||||
<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)">
|
||||
{duplicatingId === adset.id ? <Spinner size="sm" /> : <Copy size={12} />}
|
||||
</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'}>
|
||||
{actionLoading === adset.id ? <Spinner size="sm" /> : adsetActive ? <Pause size={12} /> : <Play size={12} />}
|
||||
</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">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?.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="pr-5 py-1.5">
|
||||
<div className="flex items-center gap-1">
|
||||
|
|
|
|||
|
|
@ -28,22 +28,20 @@ export async function analyzePerformance(
|
|||
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];
|
||||
const ins = c.insights as any;
|
||||
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',
|
||||
spend: `R$${parseFloat(ins?.spend || '0').toFixed(2)}`,
|
||||
ctr: `${parseFloat(ins?.ctr || '0').toFixed(2)}%`,
|
||||
cpc: `R$${parseFloat(ins?.cpc || '0').toFixed(2)}`,
|
||||
cpm: `R$${parseFloat(ins?.cpm || '0').toFixed(2)}`,
|
||||
leads: ins?.leads || 0,
|
||||
purchases: ins?.purchases || 0,
|
||||
roas: ins?.roas ? ins.roas.toFixed(2) : '0',
|
||||
};
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -129,7 +129,7 @@ export async function callMetaTool(
|
|||
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 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}}}`;
|
||||
resultData = await fetchGraphGet(`/${accountId}/campaigns`, token, { fields });
|
||||
return { content: [{ type: 'text', text: JSON.stringify(resultData.data || []) }] };
|
||||
|
|
@ -220,7 +220,7 @@ export async function callMetaTool(
|
|||
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 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}}}`;
|
||||
resultData = await fetchGraphGet(`/${args.campaign_id}`, token, { fields });
|
||||
return { content: [{ type: 'text', text: JSON.stringify(resultData) }] };
|
||||
|
|
@ -234,12 +234,21 @@ export async function callMetaTool(
|
|||
resultData = await fetchGraphPostForm(`/${args.campaign_id}`, token, updateCampPayload);
|
||||
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':
|
||||
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;
|
||||
if (args.targeting) updateAdsetPayload.targeting = args.targeting;
|
||||
resultData = await fetchGraphPostForm(`/${args.adset_id}`, token, updateAdsetPayload);
|
||||
return { content: [{ type: 'text', text: JSON.stringify(resultData) }] };
|
||||
|
||||
|
|
|
|||
|
|
@ -308,6 +308,51 @@ export async function updateAdSetBudget(
|
|||
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 — só 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) {
|
||||
requireId(adSetId, 'adSetId');
|
||||
|
||||
|
|
|
|||
|
|
@ -2,18 +2,23 @@
|
|||
// 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 {
|
||||
id: string;
|
||||
name: string;
|
||||
status: 'ACTIVE' | 'PAUSED' | 'DELETED' | 'ARCHIVED';
|
||||
insights?: {
|
||||
spend: string;
|
||||
ctr: string;
|
||||
cpc: string;
|
||||
cpm: string;
|
||||
leads: number;
|
||||
roas: number;
|
||||
};
|
||||
insights?: AdInsights;
|
||||
}
|
||||
|
||||
export interface AdSet {
|
||||
|
|
@ -24,14 +29,7 @@ export interface AdSet {
|
|||
ads?: {
|
||||
data: Ad[];
|
||||
};
|
||||
insights?: {
|
||||
spend: string;
|
||||
ctr: string;
|
||||
cpc: string;
|
||||
cpm: string;
|
||||
leads: number;
|
||||
roas: number;
|
||||
};
|
||||
insights?: AdInsights;
|
||||
}
|
||||
|
||||
export interface Campaign {
|
||||
|
|
@ -91,6 +89,8 @@ export interface AccountOverview {
|
|||
total_clicks: number;
|
||||
total_reach: number;
|
||||
total_leads: number;
|
||||
total_purchases: number;
|
||||
avg_cpa: number;
|
||||
avg_ctr: number;
|
||||
avg_cpc: number;
|
||||
avg_cpm: number;
|
||||
|
|
|
|||
Loading…
Reference in a new issue