feat: duplicate campaigns, ad sets and ads like the Meta Ads Manager

Adds a "Duplicar" button on every campaign, ad set and ad row, mirroring
the native Duplicate in Ads Manager. Uses Meta's /copies endpoint with
deep_copy for campaigns/ad sets (children come along) and always creates
the copy as PAUSED with a " - Cópia" suffix so it's safe to review before
activating. This lets users grow a campaign by cloning: duplicate an ad
for a new creative, duplicate an ad set for a new "subcampanha", or
duplicate a whole campaign.

Backend: copy_campaign/copy_adset/copy_ad tools in the Graph wrapper,
duplicateEntity() helper, and a single POST /api/meta/duplicate route.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Marcio Bevervanso 2026-06-08 14:33:24 -03:00
parent a88e96adf5
commit 38f5dc444f
4 changed files with 151 additions and 4 deletions

View file

@ -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<ReturnType<typeof createMetaMCPClient>> | 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();
}
}
}

View file

@ -1,7 +1,7 @@
'use client'; 'use client';
import React, { useState } from 'react'; import React, { useState } from 'react';
import { Pause, Play, DollarSign, Search, ArrowUpDown, ChevronDown, ChevronRight, Folder, Layers, Megaphone, Plus } 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 { Spinner } from '@/components/ui/Spinner';
import { BudgetModal } from './BudgetModal'; import { BudgetModal } from './BudgetModal';
import { AddAdModal } from './AddAdModal'; import { AddAdModal } from './AddAdModal';
@ -41,9 +41,34 @@ export function CampaignTable({ campaigns, loading, onStatusChange, onBudgetChan
adsetName: string; adsetName: string;
} | null>(null); } | null>(null);
const [duplicatingId, setDuplicatingId] = useState<string | null>(null);
const [dupError, setDupError] = useState<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 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) => { const toggleSort = (f: SortField) => {
if (sortField === f) setSortDir(d => d === 'asc' ? 'desc' : 'asc'); if (sortField === f) setSortDir(d => d === 'asc' ? 'desc' : 'asc');
else { setSortField(f); setSortDir('desc'); } else { setSortField(f); setSortDir('desc'); }
@ -107,6 +132,12 @@ export function CampaignTable({ campaigns, loading, onStatusChange, onBudgetChan
<input type="text" value={search} onChange={e => setSearch(e.target.value)} placeholder="Buscar campanha..." className="input-field text-xs" style={{ width: '220px', paddingLeft: '34px', paddingTop: '8px', paddingBottom: '8px' }} /> <input type="text" value={search} onChange={e => setSearch(e.target.value)} placeholder="Buscar campanha..." className="input-field text-xs" style={{ width: '220px', paddingLeft: '34px', paddingTop: '8px', paddingBottom: '8px' }} />
</div> </div>
</div> </div>
{dupError && (
<div className="mx-5 mt-3 px-4 py-2.5 rounded-lg text-xs flex items-center justify-between gap-3" style={{ background: 'rgba(239,68,68,0.08)', border: '1px solid rgba(239,68,68,0.15)', color: '#f87171' }}>
<span>{dupError}</span>
<button onClick={() => setDupError(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>
@ -178,6 +209,9 @@ export function CampaignTable({ campaigns, loading, onStatusChange, onBudgetChan
<DollarSign size={14} /> <DollarSign size={14} />
</button> </button>
)} )}
<button onClick={() => duplicate('campaign', c.campaign.id)} disabled={duplicatingId === c.campaign.id} className="btn-ghost p-1.5" title="Duplicar campanha (cópia completa, Pausada)">
{duplicatingId === c.campaign.id ? <Spinner size="sm" /> : <Copy size={14} />}
</button>
</div> </div>
</td> </td>
</tr> </tr>
@ -232,6 +266,9 @@ export function CampaignTable({ campaigns, loading, onStatusChange, onBudgetChan
> >
<Plus size={12} /> <Plus size={12} />
</button> </button>
<button onClick={() => duplicate('adset', adset.id)} disabled={duplicatingId === adset.id} className="p-1 rounded hover:bg-white/5 transition-colors text-[var(--muted-fg)] hover:text-white" title="Duplicar conjunto (com os anúncios, Pausado)">
{duplicatingId === adset.id ? <Spinner size="sm" /> : <Copy size={12} />}
</button>
<button onClick={() => 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>
@ -263,9 +300,14 @@ export function CampaignTable({ campaigns, loading, onStatusChange, onBudgetChan
<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}</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">
<button onClick={() => toggleStatus(ad.id, ad.status)} disabled={actionLoading === ad.id} className="p-1 rounded hover:bg-white/5 transition-colors text-[var(--muted-fg)] hover:text-white" title={adActive ? 'Pausar' : 'Reativar'}> <div className="flex items-center gap-1">
{actionLoading === ad.id ? <Spinner size="sm" /> : adActive ? <Pause size={12} /> : <Play size={12} />} <button onClick={() => duplicate('ad', ad.id)} disabled={duplicatingId === ad.id} className="p-1 rounded hover:bg-white/5 transition-colors text-[var(--muted-fg)] hover:text-white" title="Duplicar anúncio (no mesmo conjunto, Pausado)">
</button> {duplicatingId === ad.id ? <Spinner size="sm" /> : <Copy size={12} />}
</button>
<button onClick={() => toggleStatus(ad.id, ad.status)} disabled={actionLoading === ad.id} className="p-1 rounded hover:bg-white/5 transition-colors text-[var(--muted-fg)] hover:text-white" title={adActive ? 'Pausar' : 'Reativar'}>
{actionLoading === ad.id ? <Spinner size="sm" /> : adActive ? <Pause size={12} /> : <Play size={12} />}
</button>
</div>
</td> </td>
</tr> </tr>
); );

View file

@ -142,6 +142,39 @@ export async function callMetaTool(
return { content: [{ type: 'text', text: JSON.stringify(allPages) }] }; 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': case 'get_insights_for_account':
resultData = await fetchGraphGet(`/${accountId}/insights`, token, { resultData = await fetchGraphGet(`/${accountId}/insights`, token, {
date_preset: args.date_preset || 'last_30d', date_preset: args.date_preset || 'last_30d',

View file

@ -130,6 +130,32 @@ export async function resumeCampaign(client: Client, campaignId: string) {
return extractContent(result); 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<DuplicableLevel, { tool: string; argKey: string }> = {
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. * Mantido por compatibilidade.
* ATENÇÃO: orçamento normalmente é no AdSet, não na campanha. * ATENÇÃO: orçamento normalmente é no AdSet, não na campanha.