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';
|
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();
|
||||||
|
|
@ -20,7 +38,7 @@ export async function POST(request: NextRequest) {
|
||||||
|
|
||||||
const client = await createMetaMCPClient(token);
|
const client = await createMetaMCPClient(token);
|
||||||
let campaignData: any;
|
let campaignData: any;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await getCampaignDetails(client, campaignId);
|
const result = await getCampaignDetails(client, campaignId);
|
||||||
campaignData = Array.isArray(result) ? result[0] : result;
|
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 });
|
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 || [];
|
||||||
let totalSpend = parseFloat(insights.spend || '0');
|
const objective = campaignData.objective || '';
|
||||||
let roas = parseFloat(insights.purchase_roas?.[0]?.value || '0');
|
|
||||||
|
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
|
// 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(
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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>,
|
||||||
// Calculate total leads from actions
|
listCampaigns(client, undefined, datePreset) as Promise<any>,
|
||||||
const actions = rawOverview?.actions || [];
|
]);
|
||||||
const totalLeads = actions.find((a: any) => a.action_type === 'lead')?.value || 0;
|
|
||||||
|
const actions = rawOverview?.actions || [];
|
||||||
const purchaseValue = actions.find((a: any) => a.action_type === 'omni_purchase')?.value || 0;
|
const totalLeads = parseInt(actions.find((a: any) => a.action_type === 'lead' || a.action_type === 'onsite_conversion.lead_grouped')?.value || '0', 10);
|
||||||
const spend = parseFloat(rawOverview?.spend || '0');
|
const totalPurchases = parseInt(actions.find((a: any) => a.action_type === 'omni_purchase')?.value || '0', 10);
|
||||||
const roas = spend > 0 && purchaseValue ? (parseFloat(purchaseValue) / spend) : 0;
|
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 = {
|
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 });
|
||||||
|
|
|
||||||
|
|
@ -6,19 +6,24 @@ import { getAccessToken } from '@/lib/auth/session';
|
||||||
function mapInsights(rawInsights: any) {
|
function mapInsights(rawInsights: any) {
|
||||||
if (!rawInsights || !rawInsights.data || rawInsights.data.length === 0) return null;
|
if (!rawInsights || !rawInsights.data || rawInsights.data.length === 0) return null;
|
||||||
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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
<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">
|
<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">CTR</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-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>
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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) }] };
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 — 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) {
|
export async function pauseAdSet(client: Client, adSetId: string) {
|
||||||
requireId(adSetId, 'adSetId');
|
requireId(adSetId, 'adSetId');
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue