diff --git a/src/app/api/meta/duplicate/route.ts b/src/app/api/meta/duplicate/route.ts new file mode 100644 index 0000000..602a7ab --- /dev/null +++ b/src/app/api/meta/duplicate/route.ts @@ -0,0 +1,46 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { createMetaMCPClient } from '@/lib/mcp/client'; +import { duplicateEntity, type DuplicableLevel } from '@/lib/mcp/tools'; +import { getAccessToken } from '@/lib/auth/session'; + +const VALID_LEVELS: DuplicableLevel[] = ['campaign', 'adset', 'ad']; + +export async function POST(request: NextRequest) { + let client: Awaited> | undefined; + + try { + const token = await getAccessToken(); + if (!token) { + return NextResponse.json({ success: false, error: 'Not authenticated' }, { status: 401 }); + } + + const body = await request.json(); + const { level, id } = body as { level?: DuplicableLevel; id?: string }; + + if (!level || !VALID_LEVELS.includes(level)) { + return NextResponse.json( + { success: false, error: 'level inválido. Use "campaign", "adset" ou "ad".' }, + { status: 400 } + ); + } + + if (!id) { + return NextResponse.json({ success: false, error: 'id é obrigatório.' }, { status: 400 }); + } + + client = await createMetaMCPClient(token); + const data = await duplicateEntity(client, level, id); + + return NextResponse.json({ success: true, data }); + } catch (error) { + console.error('[API] Error duplicating entity:', error); + return NextResponse.json( + { success: false, error: error instanceof Error ? error.message : 'Failed to duplicate' }, + { status: 500 } + ); + } finally { + if (client) { + await client.close(); + } + } +} diff --git a/src/components/dashboard/CampaignTable.tsx b/src/components/dashboard/CampaignTable.tsx index fd39c0d..b1969aa 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 } from 'lucide-react'; +import { Pause, Play, DollarSign, Search, ArrowUpDown, ChevronDown, ChevronRight, Folder, Layers, Megaphone, Plus, Copy } from 'lucide-react'; import { Spinner } from '@/components/ui/Spinner'; import { BudgetModal } from './BudgetModal'; import { AddAdModal } from './AddAdModal'; @@ -41,9 +41,34 @@ export function CampaignTable({ campaigns, loading, onStatusChange, onBudgetChan adsetName: string; } | null>(null); + const [duplicatingId, setDuplicatingId] = useState(null); + const [dupError, setDupError] = useState(null); + // State for tracking which rows (Campaign or AdSet) are expanded const [expandedRows, setExpandedRows] = useState>(new Set()); + const duplicate = async (level: 'campaign' | 'adset' | 'ad', id: string) => { + setDuplicatingId(id); + setDupError(null); + try { + const res = await fetch('/api/meta/duplicate', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ level, id }), + }); + const data = await res.json(); + if (!data.success) { + setDupError(data.error || 'Não foi possível duplicar.'); + return; + } + onAdCreated(); // recarrega a lista — a cópia (Pausada) aparece na árvore + } catch (err) { + setDupError(err instanceof Error ? err.message : 'Erro ao duplicar.'); + } finally { + setDuplicatingId(null); + } + }; + const toggleSort = (f: SortField) => { if (sortField === f) setSortDir(d => d === 'asc' ? 'desc' : 'asc'); else { setSortField(f); setSortDir('desc'); } @@ -107,6 +132,12 @@ export function CampaignTable({ campaigns, loading, onStatusChange, onBudgetChan setSearch(e.target.value)} placeholder="Buscar campanha..." className="input-field text-xs" style={{ width: '220px', paddingLeft: '34px', paddingTop: '8px', paddingBottom: '8px' }} /> + {dupError && ( +
+ {dupError} + +
+ )}
@@ -178,6 +209,9 @@ export function CampaignTable({ campaigns, loading, onStatusChange, onBudgetChan )} + @@ -232,6 +266,9 @@ export function CampaignTable({ campaigns, loading, onStatusChange, onBudgetChan > + @@ -263,9 +300,14 @@ export function CampaignTable({ campaigns, loading, onStatusChange, onBudgetChan ); diff --git a/src/lib/mcp/client.ts b/src/lib/mcp/client.ts index 9a00144..b10154d 100644 --- a/src/lib/mcp/client.ts +++ b/src/lib/mcp/client.ts @@ -142,6 +142,39 @@ export async function callMetaTool( return { content: [{ type: 'text', text: JSON.stringify(allPages) }] }; } + // Duplicação nativa (mesmo "Duplicar" do Ads Manager) via endpoint /copies. + // Sempre cria como PAUSED e com sufixo no nome pra identificar a cópia. + case 'copy_campaign': { + requireFields(args, ['campaign_id'], toolName); + resultData = await fetchGraphPostForm(`/${args.campaign_id}/copies`, token, { + deep_copy: true, // copia ad sets + anúncios junto + status_option: 'PAUSED', + rename_options: { rename_suffix: ' - Cópia' }, + }); + return { content: [{ type: 'text', text: JSON.stringify(resultData) }] }; + } + + case 'copy_adset': { + requireFields(args, ['adset_id'], toolName); + resultData = await fetchGraphPostForm(`/${args.adset_id}/copies`, token, { + deep_copy: true, // copia os anúncios do conjunto junto + status_option: 'PAUSED', + rename_options: { rename_suffix: ' - Cópia' }, + ...(args.target_campaign_id ? { campaign_id: args.target_campaign_id } : {}), + }); + return { content: [{ type: 'text', text: JSON.stringify(resultData) }] }; + } + + case 'copy_ad': { + requireFields(args, ['ad_id'], toolName); + resultData = await fetchGraphPostForm(`/${args.ad_id}/copies`, token, { + status_option: 'PAUSED', + rename_options: { rename_suffix: ' - Cópia' }, + ...(args.target_adset_id ? { adset_id: args.target_adset_id } : {}), + }); + return { content: [{ type: 'text', text: JSON.stringify(resultData) }] }; + } + case 'get_insights_for_account': resultData = await fetchGraphGet(`/${accountId}/insights`, token, { date_preset: args.date_preset || 'last_30d', diff --git a/src/lib/mcp/tools.ts b/src/lib/mcp/tools.ts index eca3009..c7b0c03 100644 --- a/src/lib/mcp/tools.ts +++ b/src/lib/mcp/tools.ts @@ -130,6 +130,32 @@ export async function resumeCampaign(client: Client, campaignId: string) { return extractContent(result); } +export type DuplicableLevel = 'campaign' | 'adset' | 'ad'; + +/** + * Duplica uma campanha, conjunto (ad set) ou anúncio usando o endpoint nativo + * /copies da Meta — o mesmo "Duplicar" do Ads Manager. Campanhas e conjuntos + * vêm com cópia completa (deep copy: traz os filhos junto) e tudo entra como + * PAUSED, pra revisar antes de ativar. + */ +export async function duplicateEntity(client: Client, level: DuplicableLevel, id: string) { + requireId(id, 'id'); + + const config: Record = { + campaign: { tool: 'copy_campaign', argKey: 'campaign_id' }, + adset: { tool: 'copy_adset', argKey: 'adset_id' }, + ad: { tool: 'copy_ad', argKey: 'ad_id' }, + }; + + const entry = config[level]; + if (!entry) { + throw new Error(`Nível inválido para duplicar: ${level}`); + } + + const result = await callMetaTool(client, entry.tool, { [entry.argKey]: id }); + return extractContent(result); +} + /** * Mantido por compatibilidade. * ATENÇÃO: orçamento normalmente é no AdSet, não na campanha.
0 ? '#4ade80' : 'var(--muted-fg)' }}>{ad.insights?.leads || 0} = 1 ? '#4ade80' : (ad.insights?.roas || 0) > 0 ? '#fbbf24' : 'var(--muted-fg)' }}>{(ad.insights?.roas || 0) > 0 ? `${(ad.insights?.roas || 0).toFixed(2)}x` : '—'} - +
+ + +