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:
parent
a88e96adf5
commit
38f5dc444f
4 changed files with 151 additions and 4 deletions
46
src/app/api/meta/duplicate/route.ts
Normal file
46
src/app/api/meta/duplicate/route.ts
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue