From 4bde1cc5c8718a726ad8fb6168d8b9c5209f856d Mon Sep 17 00:00:00 2001 From: Marcio Bevervanso Date: Wed, 17 Jun 2026 14:34:47 -0300 Subject: [PATCH] feat: posicionamentos restritos + stats completas + auditoria IA melhorada MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- src/app/api/adsets/[id]/placements/route.ts | 32 ++++ src/app/api/analysis/route.ts | 180 ++++++++++++++------ src/app/api/campaigns/create/route.ts | 16 +- src/app/api/campaigns/insights/route.ts | 37 ++-- src/app/api/campaigns/route.ts | 15 +- src/app/dashboard/analysis/page.tsx | 46 ++++- src/app/dashboard/page.tsx | 44 +++-- src/components/dashboard/CampaignTable.tsx | 67 ++++++-- src/lib/ai/strategist.ts | 18 +- src/lib/mcp/client.ts | 13 +- src/lib/mcp/tools.ts | 45 +++++ src/lib/meta/types.ts | 32 ++-- 12 files changed, 407 insertions(+), 138 deletions(-) create mode 100644 src/app/api/adsets/[id]/placements/route.ts diff --git a/src/app/api/adsets/[id]/placements/route.ts b/src/app/api/adsets/[id]/placements/route.ts new file mode 100644 index 0000000..4baab76 --- /dev/null +++ b/src/app/api/adsets/[id]/placements/route.ts @@ -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 } + ); + } +} diff --git a/src/app/api/analysis/route.ts b/src/app/api/analysis/route.ts index 706d05f..405d4f7 100644 --- a/src/app/api/analysis/route.ts +++ b/src/app/api/analysis/route.ts @@ -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 = { + 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( diff --git a/src/app/api/campaigns/create/route.ts b/src/app/api/campaigns/create/route.ts index 25e9085..b1f0864 100644 --- a/src/app/api/campaigns/create/route.ts +++ b/src/app/api/campaigns/create/route.ts @@ -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 = { ...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, diff --git a/src/app/api/campaigns/insights/route.ts b/src/app/api/campaigns/insights/route.ts index 157879b..621dbab 100644 --- a/src/app/api/campaigns/insights/route.ts +++ b/src/app/api/campaigns/insights/route.ts @@ -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, + listCampaigns(client, undefined, datePreset) as Promise, + ]); + + 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 }); diff --git a/src/app/api/campaigns/route.ts b/src/app/api/campaigns/route.ts index 839dba0..69665b0 100644 --- a/src/app/api/campaigns/route.ts +++ b/src/app/api/campaigns/route.ts @@ -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, }; } diff --git a/src/app/dashboard/analysis/page.tsx b/src/app/dashboard/analysis/page.tsx index 64aacc0..9bd6b98 100644 --- a/src/app/dashboard/analysis/page.tsx +++ b/src/app/dashboard/analysis/page.tsx @@ -192,18 +192,46 @@ export default function AnalysisPage() { {report && !analyzing && (
{/* Métricas Hero */} -
+
-

Anúncios Auditados

+

Anúncios

{report.ads_count}

+

{report.campaign_metrics.objective_label || '—'}

Gasto (30d)

R$ {parseFloat(report.campaign_metrics.spend).toFixed(2)}

+

{Number(report.campaign_metrics.impressions || 0).toLocaleString('pt-BR')} impressões

+ {report.campaign_metrics.leads > 0 && ( +
+

Leads

+

{report.campaign_metrics.leads}

+ {report.campaign_metrics.cpl && ( +

CPL: R$ {parseFloat(report.campaign_metrics.cpl).toFixed(2)}

+ )} +
+ )} + {report.campaign_metrics.purchases > 0 && ( +
+

Compras

+

{report.campaign_metrics.purchases}

+ {report.campaign_metrics.cpa && ( +

CPA: R$ {parseFloat(report.campaign_metrics.cpa).toFixed(2)}

+ )} +
+ )} + {report.campaign_metrics.roas && ( +
+

ROAS

+

{parseFloat(report.campaign_metrics.roas).toFixed(2)}x

+

Retorno sobre investimento

+
+ )}
-

ROAS

-

{parseFloat(report.campaign_metrics.roas).toFixed(2)}x

+

CTR

+

{parseFloat(report.campaign_metrics.ctr || '0').toFixed(2)}%

+

CPC: R$ {parseFloat(report.campaign_metrics.cpc || '0').toFixed(2)}

@@ -228,9 +256,15 @@ export default function AnalysisPage() { )}

{ad.ad_name}

-
+
Gasto: R$ {parseFloat(ad.spend).toFixed(2)} - ROAS: {parseFloat(ad.roas).toFixed(2)}x + CTR: {ad.ctr} +
+
+ {ad.leads > 0 && Leads: {ad.leads} {ad.cpl ? `· CPL: ${ad.cpl}` : ''}} + {ad.purchases > 0 && Compras: {ad.purchases} {ad.cpa ? `· CPA: ${ad.cpa}` : ''}} + {ad.roas && ROAS: {ad.roas}} + {!ad.leads && !ad.purchases && !ad.roas && CPC: {ad.cpc}}

{ad.body || 'Sem texto'}

diff --git a/src/app/dashboard/page.tsx b/src/app/dashboard/page.tsx index d753919..ce241d2 100644 --- a/src/app/dashboard/page.tsx +++ b/src/app/dashboard/page.tsx @@ -130,13 +130,13 @@ export default function DashboardPage() { {/* Metrics Cards */} {loading ? ( -
- {Array.from({ length: 6 }).map((_, i) => ( +
+ {Array.from({ length: 8 }).map((_, i) => (
))}
) : overview ? ( -
+
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} /> + 0 ? `CPL: ${formatCurrency(overview.total_spend / overview.total_leads)}` : 'Custo por lead'} + icon="users" + color="amber" + delay={4} + /> +
) : null} diff --git a/src/components/dashboard/CampaignTable.tsx b/src/components/dashboard/CampaignTable.tsx index dff9e01..2771bf2 100644 --- a/src/components/dashboard/CampaignTable.tsx +++ b/src/components/dashboard/CampaignTable.tsx @@ -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(null); const [dupError, setDupError] = useState(null); + const [fixingPlacementsId, setFixingPlacementsId] = useState(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>(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
)} + {placementsMsg && ( +
+ {placementsMsg.text} + +
+ )}
@@ -150,15 +182,17 @@ export function CampaignTable({ campaigns, loading, onStatusChange, onBudgetChan + {sorted.length === 0 ? ( - + ) : 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 - + + - + + - + +
Ações

{search ? 'Nenhuma campanha encontrada' : 'Nenhuma campanha ativa'}

{search ? 'Nenhuma campanha encontrada' : 'Nenhuma campanha ativa'}

{parseFloat(c.insights?.ctr || '0').toFixed(2)}% R$ {parseFloat(c.insights?.cpc || '0').toFixed(2)} R$ {parseFloat(c.insights?.cpm || '0').toFixed(2)} 0 ? '#4ade80' : 'var(--muted-fg)' }}>{leads} 0 ? '#4ade80' : 'var(--muted-fg)' }}>{leads > 0 ? leads : '—'} 0 ? '#4ade80' : 'var(--muted-fg)' }}>{purchases > 0 ? purchases : '—'} = 1 ? '#4ade80' : roas > 0 ? '#fbbf24' : 'var(--muted-fg)' }}>{roas > 0 ? `${roas.toFixed(2)}x` : '—'}
@@ -250,7 +285,8 @@ export function CampaignTable({ campaigns, loading, onStatusChange, onBudgetChan
{parseFloat(adset.insights?.ctr || '0').toFixed(2)}% R$ {parseFloat(adset.insights?.cpc || '0').toFixed(2)} R$ {parseFloat(adset.insights?.cpm || '0').toFixed(2)} 0 ? '#4ade80' : 'var(--muted-fg)' }}>{adset.insights?.leads || 0} 0 ? '#4ade80' : 'var(--muted-fg)' }}>{(adset.insights?.leads || 0) > 0 ? adset.insights?.leads : '—'} 0 ? '#4ade80' : 'var(--muted-fg)' }}>{((adset.insights as any)?.purchases || 0) > 0 ? (adset.insights as any).purchases : '—'} = 1 ? '#4ade80' : (adset.insights?.roas || 0) > 0 ? '#fbbf24' : 'var(--muted-fg)' }}>{(adset.insights?.roas || 0) > 0 ? `${(adset.insights?.roas || 0).toFixed(2)}x` : '—'}
@@ -269,6 +305,14 @@ export function CampaignTable({ campaigns, loading, onStatusChange, onBudgetChan + @@ -297,7 +341,8 @@ export function CampaignTable({ campaigns, loading, onStatusChange, onBudgetChan
{parseFloat(ad.insights?.ctr || '0').toFixed(2)}% R$ {parseFloat(ad.insights?.cpc || '0').toFixed(2)} R$ {parseFloat(ad.insights?.cpm || '0').toFixed(2)} 0 ? '#4ade80' : 'var(--muted-fg)' }}>{ad.insights?.leads || 0} 0 ? '#4ade80' : 'var(--muted-fg)' }}>{(ad.insights?.leads || 0) > 0 ? ad.insights?.leads : '—'} 0 ? '#4ade80' : 'var(--muted-fg)' }}>{((ad.insights as any)?.purchases || 0) > 0 ? (ad.insights as any).purchases : '—'} = 1 ? '#4ade80' : (ad.insights?.roas || 0) > 0 ? '#fbbf24' : 'var(--muted-fg)' }}>{(ad.insights?.roas || 0) > 0 ? `${(ad.insights?.roas || 0).toFixed(2)}x` : '—'}
diff --git a/src/lib/ai/strategist.ts b/src/lib/ai/strategist.ts index aac5121..a1c3d98 100644 --- a/src/lib/ai/strategist.ts +++ b/src/lib/ai/strategist.ts @@ -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', }; }); diff --git a/src/lib/mcp/client.ts b/src/lib/mcp/client.ts index 0326c86..f6a27d1 100644 --- a/src/lib/mcp/client.ts +++ b/src/lib/mcp/client.ts @@ -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) }] }; diff --git a/src/lib/mcp/tools.ts b/src/lib/mcp/tools.ts index 759f63f..dab6846 100644 --- a/src/lib/mcp/tools.ts +++ b/src/lib/mcp/tools.ts @@ -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> { + 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 { + 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'); diff --git a/src/lib/meta/types.ts b/src/lib/meta/types.ts index 96da0dc..7103182 100644 --- a/src/lib/meta/types.ts +++ b/src/lib/meta/types.ts @@ -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;