feat: permite adicionar novos anuncios a campanhas ja ativas

Adiciona modal "+ Anuncio" na tabela de campanhas que reaproveita o
fluxo de geracao de copy/imagem por IA para publicar novos criativos
no mesmo ad set existente, mantendo orcamento e segmentacao.

- Extrai pipeline de upload+criativo+anuncio (addAdToAdSet) do
  createFullCampaign para reuso
- Adiciona get_adset_default_creative para pre-preencher pagina,
  link, instagram e CTA com base em anuncio ja existente no ad set
- Novas rotas POST /api/adsets/[id]/ads e
  GET /api/adsets/[id]/creative-defaults
- Novo componente AddAdModal com geracao de copy/imagem via IA ou
  upload manual

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Marcio Bevervanso 2026-06-08 12:54:00 -03:00
parent f47c9e58ce
commit d2406f0152
7 changed files with 849 additions and 99 deletions

View file

@ -0,0 +1,153 @@
import { NextRequest, NextResponse } from 'next/server';
import { createMetaMCPClient } from '@/lib/mcp/client';
import { addAdToAdSet, getAdSetDefaultCreative } from '@/lib/mcp/tools';
import { getAccessToken } from '@/lib/auth/session';
function isWhatsappAd(body: Record<string, unknown>) {
const cta = String(body.cta || '').toUpperCase();
return (
body.is_whatsapp === true ||
cta === 'WHATSAPP_MESSAGE' ||
Boolean(body.whatsapp_link) ||
Boolean(body.whatsapp_phone_number)
);
}
export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
let mcpClient: Awaited<ReturnType<typeof createMetaMCPClient>> | undefined;
try {
const token = await getAccessToken();
if (!token) {
return NextResponse.json({ success: false, error: 'Not authenticated' }, { status: 401 });
}
const { id: adSetId } = await params;
const body = await request.json();
const {
campaign_name,
page_id,
instagram_actor_id,
image_urls,
image_bytes,
link,
headline,
primary_text,
description,
cta,
ad_account_id,
whatsapp_link,
whatsapp_phone_number,
} = body;
if (!headline) {
return NextResponse.json({ success: false, error: 'headline é obrigatório.' }, { status: 400 });
}
if (!primary_text) {
return NextResponse.json({ success: false, error: 'primary_text é obrigatório.' }, { status: 400 });
}
const hasUrls = Array.isArray(image_urls) && image_urls.length > 0;
const hasBytes = Array.isArray(image_bytes) && image_bytes.length > 0;
if (!hasUrls && !hasBytes) {
return NextResponse.json(
{ success: false, error: 'image_urls ou image_bytes (array) é obrigatório.' },
{ status: 400 }
);
}
if (!link && !whatsapp_link && !whatsapp_phone_number) {
return NextResponse.json(
{ success: false, error: 'link, whatsapp_link ou whatsapp_phone_number é obrigatório.' },
{ status: 400 }
);
}
mcpClient = await createMetaMCPClient(token);
// Completa page_id/instagram/cta/link ausentes com base em um anúncio
// já existente no ad set (mesmo destino/página da campanha atual).
let finalPageId = page_id;
let finalInstagramActorId = instagram_actor_id;
let finalLink = link;
let finalCta = cta;
if (!finalPageId || !finalLink) {
const defaults = await getAdSetDefaultCreative(mcpClient, adSetId);
finalPageId = finalPageId || defaults.page_id;
finalInstagramActorId = finalInstagramActorId || defaults.instagram_actor_id;
finalLink = finalLink || defaults.link;
finalCta = finalCta || defaults.cta;
}
if (!finalPageId) {
return NextResponse.json(
{
success: false,
error: 'Nenhuma página Meta encontrada para este ad set. Envie page_id manualmente.',
},
{ status: 400 }
);
}
const finalIsWhatsapp = isWhatsappAd({ ...body, cta: finalCta });
if (!finalLink && !whatsapp_link && !whatsapp_phone_number) {
return NextResponse.json(
{
success: false,
error: 'Não foi possível determinar o link de destino do anúncio. Envie link, whatsapp_link ou whatsapp_phone_number.',
},
{ status: 400 }
);
}
const adName = `${campaign_name || 'Anúncio'}${headline}`.slice(0, 255);
const result = await addAdToAdSet(mcpClient, {
adset_id: adSetId,
ad_name: adName,
page_id: finalPageId,
instagram_actor_id: finalInstagramActorId || undefined,
image_urls,
image_bytes,
link: finalLink,
headline,
primary_text,
description: description || '',
cta: finalCta || (finalIsWhatsapp ? 'WHATSAPP_MESSAGE' : 'LEARN_MORE'),
ad_account_id,
is_whatsapp: finalIsWhatsapp,
whatsapp_link,
whatsapp_phone_number,
});
return NextResponse.json({ success: true, data: result });
} catch (error) {
console.error('Add Ad API Error:', error);
const message = error instanceof Error ? error.message : 'Internal Server Error';
return NextResponse.json(
{
success: false,
error: message,
details: {
message,
stack: process.env.NODE_ENV === 'development' && error instanceof Error ? error.stack : undefined,
},
},
{ status: 500 }
);
} finally {
if (mcpClient) {
await mcpClient.close();
}
}
}

View file

@ -0,0 +1,32 @@
import { NextRequest, NextResponse } from 'next/server';
import { createMetaMCPClient } from '@/lib/mcp/client';
import { getAdSetDefaultCreative } from '@/lib/mcp/tools';
import { getAccessToken } from '@/lib/auth/session';
export async function GET(
_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 {
const defaults = await getAdSetDefaultCreative(client, id);
return NextResponse.json({ success: true, data: defaults });
} finally {
await client.close();
}
} catch (error) {
console.error('[API] Error fetching ad set creative defaults:', error);
return NextResponse.json(
{ success: false, error: error instanceof Error ? error.message : 'Failed to fetch creative defaults' },
{ status: 500 }
);
}
}

View file

@ -194,6 +194,7 @@ export default function DashboardPage() {
loading={loading}
onStatusChange={handleStatusChange}
onBudgetChange={handleBudgetChange}
onAdCreated={() => fetchData(false)}
/>
{/* Footer */}

View file

@ -0,0 +1,371 @@
'use client';
import React, { useEffect, useState } from 'react';
import { Wand2, Upload, CheckCircle2 } from 'lucide-react';
import { Modal } from '@/components/ui/Modal';
import { Spinner } from '@/components/ui/Spinner';
import { ErrorBlock } from '@/components/ui/ErrorBlock';
import { parseApiError, type FriendlyError } from '@/lib/errors';
import type { AdCopyVariation } from '@/lib/ai/copywriter';
interface AddAdModalProps {
isOpen: boolean;
onClose: () => void;
campaignName: string;
campaignObjective: string;
adsetId: string;
adsetName: string;
onCreated: () => void;
}
interface CreativeDefaults {
page_id?: string;
instagram_actor_id?: string;
link?: string;
cta?: string;
is_whatsapp: boolean;
whatsapp_link?: string;
whatsapp_phone_number?: string;
}
export function AddAdModal({
isOpen,
onClose,
campaignName,
campaignObjective,
adsetId,
adsetName,
onCreated,
}: AddAdModalProps) {
const [context, setContext] = useState('');
const [link, setLink] = useState('');
const [defaults, setDefaults] = useState<CreativeDefaults | null>(null);
const [defaultsLoading, setDefaultsLoading] = useState(false);
const [copyLoading, setCopyLoading] = useState(false);
const [copyVariations, setCopyVariations] = useState<AdCopyVariation[]>([]);
const [selectedCopy, setSelectedCopy] = useState<number | null>(null);
const [imageLoading, setImageLoading] = useState(false);
const [generatedImages, setGeneratedImages] = useState<string[]>([]);
const [selectedImage, setSelectedImage] = useState<string | null>(null);
const [uploadedImage, setUploadedImage] = useState<string | null>(null);
const [publishing, setPublishing] = useState(false);
const [error, setError] = useState<FriendlyError | string | null>(null);
const [done, setDone] = useState(false);
useEffect(() => {
let cancelled = false;
(async () => {
setDefaultsLoading(true);
try {
const res = await fetch(`/api/adsets/${adsetId}/creative-defaults`);
const data = await res.json();
if (!cancelled && data.success) {
setDefaults(data.data);
setLink(data.data?.link || '');
}
} catch {
// Segue sem defaults — o usuário ainda pode publicar informando o link.
} finally {
if (!cancelled) setDefaultsLoading(false);
}
})();
return () => {
cancelled = true;
};
}, [adsetId]);
const generateCopy = async () => {
if (!context.trim()) {
setError('Descreva sobre o que é esse anúncio antes de gerar as copies.');
return;
}
setCopyLoading(true);
setError(null);
try {
const res = await fetch('/api/ai/generate-copy', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
context,
objective: campaignObjective || 'OUTCOME_TRAFFIC',
count: 3,
category: 'generic',
}),
});
const data = await res.json();
if (!data.success) {
setError(parseApiError(data, 'Não foi possível gerar as copies.'));
return;
}
setCopyVariations(data.data);
setSelectedCopy(0);
} catch (err) {
setError(err instanceof Error ? err.message : 'Erro ao gerar copies.');
} finally {
setCopyLoading(false);
}
};
const generateImages = async () => {
if (!context.trim()) {
setError('Descreva sobre o que é esse anúncio antes de gerar as imagens.');
return;
}
setImageLoading(true);
setError(null);
try {
const res = await fetch('/api/ai/generate-image', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
context,
style: 'fotografia profissional, cores vibrantes, alta conversão',
}),
});
const data = await res.json();
if (!data.success) {
setError(parseApiError(data, 'Não foi possível gerar as imagens.'));
return;
}
const urls = (data.data as Array<{ url: string }>).map((d) => d.url);
setGeneratedImages(urls);
setSelectedImage(urls[0] || null);
setUploadedImage(null);
} catch (err) {
setError(err instanceof Error ? err.message : 'Erro ao gerar imagens.');
} finally {
setImageLoading(false);
}
};
const handleFileUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (event) => {
const dataUrl = event.target?.result as string;
setUploadedImage(dataUrl);
setSelectedImage(dataUrl);
setGeneratedImages([]);
};
reader.readAsDataURL(file);
};
const selectedCopyData = selectedCopy !== null ? copyVariations[selectedCopy] : null;
const canPublish =
!!selectedCopyData &&
!!selectedImage &&
(!!link.trim() || !!defaults?.whatsapp_link || !!defaults?.whatsapp_phone_number);
const publish = async () => {
if (!canPublish || !selectedCopyData || !selectedImage) {
setError('Selecione uma copy e uma imagem antes de publicar.');
return;
}
setPublishing(true);
setError(null);
try {
const isDataUri = selectedImage.startsWith('data:image');
const res = await fetch(`/api/adsets/${adsetId}/ads`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
campaign_name: campaignName,
page_id: defaults?.page_id,
instagram_actor_id: defaults?.instagram_actor_id,
link: link.trim() || undefined,
headline: selectedCopyData.headline,
primary_text: selectedCopyData.primary_text,
description: selectedCopyData.description,
cta: selectedCopyData.cta,
image_urls: !isDataUri ? [selectedImage] : undefined,
image_bytes: isDataUri ? [selectedImage] : undefined,
is_whatsapp: defaults?.is_whatsapp,
whatsapp_link: defaults?.whatsapp_link,
whatsapp_phone_number: defaults?.whatsapp_phone_number,
}),
});
const data = await res.json();
if (!data.success) {
setError(parseApiError(data, 'Não foi possível publicar o anúncio na Meta.'));
return;
}
setDone(true);
onCreated();
} catch (err) {
setError(err instanceof Error ? err.message : 'Erro ao publicar o anúncio.');
} finally {
setPublishing(false);
}
};
return (
<Modal isOpen={isOpen} onClose={onClose} title="Adicionar Anúncio">
<div className="flex flex-col gap-5">
<p className="text-sm" style={{ color: 'var(--muted-fg)' }}>
Campanha: <span className="text-white font-medium">{campaignName}</span>
{' · '}
Ad Set: <span className="text-white font-medium">{adsetName}</span>
</p>
<p className="text-xs" style={{ color: 'var(--muted-fg)' }}>
O novo anúncio será publicado dentro deste ad set, compartilhando o orçamento e a segmentação configurados apenas com copy e imagem novas.
</p>
{error && <ErrorBlock error={typeof error === 'string' ? error : error} />}
{done ? (
<div className="flex flex-col items-center justify-center gap-3 py-8 text-center">
<CheckCircle2 size={40} className="text-green-400" />
<p className="text-white font-medium">Anúncio publicado com sucesso!</p>
<p className="text-xs" style={{ color: 'var(--muted-fg)' }}>
Ele foi criado como Pausado ative-o no Ads Manager (ou na tabela) quando estiver pronto.
</p>
<button onClick={onClose} className="btn-primary mt-2">Fechar</button>
</div>
) : (
<>
<div>
<label className="block text-xs uppercase tracking-wider font-medium mb-2" style={{ color: 'var(--muted-fg)' }}>
Sobre o que é esse anúncio?
</label>
<textarea
value={context}
onChange={(e) => setContext(e.target.value)}
placeholder="Ex: Promoção de fim de ano, novo recurso do produto, depoimento de cliente..."
rows={3}
className="input-field text-sm"
/>
</div>
{/* Copy */}
<div>
<div className="flex items-center justify-between mb-2">
<label className="text-xs uppercase tracking-wider font-medium" style={{ color: 'var(--muted-fg)' }}>
Texto do Anúncio
</label>
<button onClick={generateCopy} disabled={copyLoading} className="btn-ghost text-xs flex items-center gap-1.5 py-1.5 px-3">
{copyLoading ? <Spinner size="sm" /> : <Wand2 size={13} />}
Gerar com IA
</button>
</div>
{copyVariations.length > 0 && (
<div className="flex flex-col gap-2">
{copyVariations.map((variation, i) => (
<button
key={i}
onClick={() => setSelectedCopy(i)}
className="text-left p-3 rounded-xl transition-all"
style={{
border: selectedCopy === i ? '1px solid var(--accent)' : '1px solid var(--overlay-06)',
background: selectedCopy === i ? 'var(--overlay-04)' : 'transparent',
}}
>
<p className="text-sm font-medium text-white">{variation.headline}</p>
<p className="text-xs mt-1" style={{ color: 'var(--muted-fg)' }}>{variation.primary_text}</p>
</button>
))}
</div>
)}
</div>
{/* Image */}
<div>
<div className="flex items-center justify-between mb-2">
<label className="text-xs uppercase tracking-wider font-medium" style={{ color: 'var(--muted-fg)' }}>
Imagem do Anúncio
</label>
<div className="flex items-center gap-2">
<button onClick={generateImages} disabled={imageLoading} className="btn-ghost text-xs flex items-center gap-1.5 py-1.5 px-3">
{imageLoading ? <Spinner size="sm" /> : <Wand2 size={13} />}
Gerar com IA
</button>
<label className="btn-ghost text-xs flex items-center gap-1.5 py-1.5 px-3 cursor-pointer">
<Upload size={13} />
Upload
<input type="file" accept="image/*" className="hidden" onChange={handleFileUpload} />
</label>
</div>
</div>
{(generatedImages.length > 0 || uploadedImage) && (
<div className="grid grid-cols-3 gap-2">
{(uploadedImage ? [uploadedImage] : generatedImages).map((img, i) => (
<button
key={i}
onClick={() => setSelectedImage(img)}
className="relative rounded-xl overflow-hidden aspect-square transition-all"
style={{ border: selectedImage === img ? '2px solid var(--accent)' : '2px solid var(--overlay-06)' }}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={img} alt={`Opção ${i + 1}`} className="w-full h-full object-cover" />
{selectedImage === img && (
<div className="absolute top-1.5 right-1.5 bg-[var(--accent)] rounded-full p-0.5">
<CheckCircle2 size={14} className="text-white" />
</div>
)}
</button>
))}
</div>
)}
</div>
{/* Link */}
<div>
<label className="block text-xs uppercase tracking-wider font-medium mb-2" style={{ color: 'var(--muted-fg)' }}>
Link de destino
</label>
<input
type="text"
value={link}
onChange={(e) => setLink(e.target.value)}
placeholder={defaultsLoading ? 'Carregando destino atual da campanha...' : 'https://seusite.com.br'}
className="input-field text-sm"
disabled={!!defaults?.is_whatsapp}
/>
{defaults?.is_whatsapp && (
<p className="text-xs mt-1.5" style={{ color: 'var(--muted-fg)' }}>
Esta campanha direciona para o WhatsApp o anúncio usará o mesmo destino dos demais anúncios deste ad set.
</p>
)}
</div>
<div className="flex gap-3 mt-1">
<button onClick={onClose} className="btn-ghost flex-1" disabled={publishing}>
Cancelar
</button>
<button onClick={publish} className="btn-primary flex-1 flex items-center justify-center gap-2" disabled={!canPublish || publishing}>
{publishing ? (
<>
<Spinner size="sm" />
Publicando...
</>
) : (
'Publicar Anúncio'
)}
</button>
</div>
</>
)}
</div>
</Modal>
);
}

View file

@ -1,9 +1,10 @@
'use client';
import React, { useState } from 'react';
import { Pause, Play, DollarSign, Search, ArrowUpDown, ChevronDown, ChevronRight, Folder, Layers, Megaphone } from 'lucide-react';
import { Pause, Play, DollarSign, Search, ArrowUpDown, ChevronDown, ChevronRight, Folder, Layers, Megaphone, Plus } from 'lucide-react';
import { Spinner } from '@/components/ui/Spinner';
import { BudgetModal } from './BudgetModal';
import { AddAdModal } from './AddAdModal';
import type { CampaignWithInsights } from '@/lib/meta/types';
interface CampaignTableProps {
@ -11,6 +12,7 @@ interface CampaignTableProps {
loading: boolean;
onStatusChange: (id: string, s: 'ACTIVE' | 'PAUSED') => Promise<void>;
onBudgetChange: (id: string, b: number) => Promise<void>;
onAdCreated: () => void;
}
type SortField = 'name' | 'status' | 'spend' | 'ctr' | 'cpc' | 'cpm' | 'leads' | 'roas';
@ -26,12 +28,18 @@ function getRoas(c: CampaignWithInsights): number {
return c.insights?.purchase_roas?.[0] ? parseFloat(c.insights.purchase_roas[0].value) : 0;
}
export function CampaignTable({ campaigns, loading, onStatusChange, onBudgetChange }: CampaignTableProps) {
export function CampaignTable({ campaigns, loading, onStatusChange, onBudgetChange, onAdCreated }: CampaignTableProps) {
const [search, setSearch] = useState('');
const [sortField, setSortField] = useState<SortField>('spend');
const [sortDir, setSortDir] = useState<'asc' | 'desc'>('desc');
const [actionLoading, setActionLoading] = useState<string | null>(null);
const [budgetModal, setBudgetModal] = useState<{ campaignId: string; campaignName: string; currentBudget: number } | null>(null);
const [addAdModal, setAddAdModal] = useState<{
campaignName: string;
campaignObjective: string;
adsetId: string;
adsetName: string;
} | null>(null);
// State for tracking which rows (Campaign or AdSet) are expanded
const [expandedRows, setExpandedRows] = useState<Set<string>>(new Set());
@ -211,10 +219,23 @@ export function CampaignTable({ campaigns, loading, onStatusChange, onBudgetChan
<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?.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">
{/* Simple status toggle for adset */}
<div className="flex items-center gap-1">
<button
onClick={() => setAddAdModal({
campaignName: c.campaign.name,
campaignObjective: c.campaign.objective,
adsetId: adset.id,
adsetName: adset.name,
})}
className="p-1 rounded hover:bg-white/5 transition-colors text-[var(--muted-fg)] hover:text-white"
title="Adicionar anúncio a este ad set"
>
<Plus 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'}>
{actionLoading === adset.id ? <Spinner size="sm" /> : adsetActive ? <Pause size={12} /> : <Play size={12} />}
</button>
</div>
</td>
</tr>
@ -260,6 +281,17 @@ export function CampaignTable({ campaigns, loading, onStatusChange, onBudgetChan
</div>
</div>
{budgetModal && <BudgetModal isOpen={!!budgetModal} onClose={() => setBudgetModal(null)} campaignId={budgetModal.campaignId} campaignName={budgetModal.campaignName} currentBudget={budgetModal.currentBudget} onSave={onBudgetChange} />}
{addAdModal && (
<AddAdModal
isOpen={!!addAdModal}
onClose={() => setAddAdModal(null)}
campaignName={addAdModal.campaignName}
campaignObjective={addAdModal.campaignObjective}
adsetId={addAdModal.adsetId}
adsetName={addAdModal.adsetName}
onCreated={onAdCreated}
/>
)}
</>
);
}

View file

@ -139,6 +139,14 @@ export async function callMetaTool(
});
return { content: [{ type: 'text', text: JSON.stringify(resultData.data?.[0] || {}) }] };
case 'get_adset_default_creative': {
requireFields(args, ['adset_id'], toolName);
resultData = await fetchGraphGet(`/${args.adset_id}`, token, {
fields: 'id,name,ads.limit(1){creative{object_story_spec,asset_feed_spec}}',
});
return { content: [{ type: 'text', text: JSON.stringify(resultData) }] };
}
case 'get_campaign_details': {
requireFields(args, ['campaign_id'], toolName);
const preset = args.date_preset || 'last_30d';

View file

@ -243,6 +243,106 @@ export async function resumeAdSet(client: Client, adSetId: string) {
return extractContent(result);
}
export interface AdSetDefaultCreative {
page_id?: string;
instagram_actor_id?: string;
link?: string;
cta?: string;
is_whatsapp: boolean;
whatsapp_link?: string;
whatsapp_phone_number?: string;
}
interface AdSetDefaultCreativeApiResponse {
ads?: {
data?: Array<{
creative?: {
object_story_spec?: {
page_id?: string;
instagram_actor_id?: string;
link_data?: {
link?: string;
call_to_action?: { type?: string };
};
};
asset_feed_spec?: {
link_urls?: Array<{ website_url?: string }>;
call_to_action_types?: string[];
};
};
}>;
};
}
interface MetaPageSummary {
id: string;
instagram_business_account?: { id?: string };
}
/**
* Extrai page_id/link/instagram_actor_id/cta do criativo de um anúncio
* existente no ad set, para pré-preencher a publicação de novos anúncios no
* mesmo destino. Se o ad set ainda não tiver anúncios, cai no fallback de
* usar a primeira página conectada à conta (mesmo padrão de campaigns/create).
*/
export async function getAdSetDefaultCreative(
client: Client,
adSetId: string
): Promise<AdSetDefaultCreative> {
requireId(adSetId, 'adSetId');
const result = await callMetaTool(client, 'get_adset_default_creative', { adset_id: adSetId });
const data = extractContent(result) as AdSetDefaultCreativeApiResponse | null;
const creative = data?.ads?.data?.[0]?.creative;
const storySpec = creative?.object_story_spec;
const assetFeed = creative?.asset_feed_spec;
if (storySpec) {
const linkData = storySpec.link_data;
const ctaType = linkData?.call_to_action?.type;
const isWhatsapp = ctaType === 'WHATSAPP_MESSAGE';
return {
page_id: storySpec.page_id || undefined,
instagram_actor_id: storySpec.instagram_actor_id || undefined,
link: linkData?.link || undefined,
cta: ctaType,
is_whatsapp: isWhatsapp,
whatsapp_link: isWhatsapp ? linkData?.link || undefined : undefined,
whatsapp_phone_number: undefined,
};
}
if (assetFeed) {
const ctaType = assetFeed.call_to_action_types?.[0];
const isWhatsapp = ctaType === 'WHATSAPP_MESSAGE';
const link = assetFeed.link_urls?.[0]?.website_url;
return {
page_id: undefined,
instagram_actor_id: undefined,
link,
cta: ctaType,
is_whatsapp: isWhatsapp,
whatsapp_link: isWhatsapp ? link : undefined,
whatsapp_phone_number: undefined,
};
}
// Fallback: nenhum anúncio existente — usa a primeira página da conta.
const pages = (await getPages(client)) as MetaPageSummary[] | null;
const firstPage = Array.isArray(pages) ? pages.find((p) => p?.id) : undefined;
return {
page_id: firstPage?.id,
instagram_actor_id: firstPage?.instagram_business_account?.id,
link: undefined,
cta: undefined,
is_whatsapp: false,
};
}
// ==============================
// Campaign Creation
// ==============================
@ -417,6 +517,136 @@ export async function uploadAdImage(
return extractContent(result);
}
// ==============================
// Creative + Ad Pipeline (reusável: campanha nova ou ad set existente)
// ==============================
export interface AddAdToAdSetParams {
adset_id: string;
ad_name: string;
page_id: string;
instagram_actor_id?: string;
image_urls?: string[];
image_bytes?: string[];
link: string;
headline: string;
primary_text: string;
description: string;
cta: string;
ad_account_id?: string;
is_whatsapp?: boolean;
whatsapp_link?: string;
whatsapp_phone_number?: string;
}
/**
* Sobe imagens, cria o ad creative (normal ou whatsapp) e o anúncio dentro de
* um ad set existente. Usado tanto pelo pipeline de criação completa de
* campanha quanto para adicionar novos anúncios a uma campanha ativa.
*/
export async function addAdToAdSet(client: Client, params: AddAdToAdSetParams) {
const imageHashes: string[] = [];
const DATA_URI_RE = /^data:image\/[a-zA-Z0-9.+-]+;base64,(.+)$/;
const processImageUploads = async (urls?: string[], bytes?: string[]) => {
const allBytes = [...(bytes || [])];
const realUrls: string[] = [];
// gpt-image-2 returns base64 data URIs (data:image/png;base64,...) — Meta's
// upload_ad_image expects those as raw base64 via image_bytes, not image_url
// (sending a data URI as image_url causes "tipo de arquivo não suportado").
for (const url of urls || []) {
const match = DATA_URI_RE.exec(url);
if (match) {
allBytes.push(match[1]);
} else {
realUrls.push(url);
}
}
for (const url of realUrls) {
const res = await uploadAdImage(client, url, undefined, params.ad_account_id);
if ((res as any)?.hash) imageHashes.push((res as any).hash);
}
for (const b of allBytes) {
const res = await uploadAdImage(client, undefined, b, params.ad_account_id);
if ((res as any)?.hash) imageHashes.push((res as any).hash);
}
};
await processImageUploads(params.image_urls, params.image_bytes);
if (imageHashes.length === 0) {
throw new Error('[Upload] Nenhuma imagem foi enviada ou processada.');
}
const creativeName = `${params.ad_name} — Creative`;
// Use a single primary image via the standard object_story_spec path —
// asset_feed_spec (Asset Customization / Dynamic Creative) requires the ad
// set to be created with is_dynamic_creative=true, which we don't set here,
// and combining it with degrees_of_freedom_spec triggers Meta error subcode
// 2490433 ("unexpected error"). The first generated image (1080x1080) is
// the most broadly compatible across placements.
const primaryImageHash = imageHashes[0];
let creative;
if (params.is_whatsapp) {
creative = await createWhatsappAdCreative(client, {
name: creativeName,
page_id: params.page_id,
instagram_actor_id: params.instagram_actor_id,
image_hash: primaryImageHash,
message: params.primary_text,
headline: params.headline,
description: params.description,
link: params.link,
whatsapp_link: params.whatsapp_link,
whatsapp_phone_number: params.whatsapp_phone_number,
ad_account_id: params.ad_account_id,
});
} else {
creative = await createAdCreative(client, {
name: creativeName,
page_id: params.page_id,
instagram_actor_id: params.instagram_actor_id,
image_hash: primaryImageHash,
link: params.link,
message: params.primary_text,
headline: params.headline,
description: params.description,
call_to_action_type: params.cta,
ad_account_id: params.ad_account_id,
});
}
const creativeId = (creative as any)?.id;
if (!creativeId) {
throw new Error(`[Creative] Falha no criativo: ${JSON.stringify(creative)}`);
}
const ad = await createAd(client, {
name: `${params.ad_name} — Ad`,
adset_id: params.adset_id,
creative_id: creativeId,
ad_account_id: params.ad_account_id,
});
const adId = (ad as any)?.id;
if (!adId) {
throw new Error(`[Ad] Falha no anúncio: ${JSON.stringify(ad)}`);
}
return {
adset_id: params.adset_id,
creative_id: creativeId,
ad_id: adId,
};
}
// ==============================
// Full Campaign Creation Pipeline
// ==============================
@ -504,101 +734,24 @@ export async function createFullCampaign(client: Client, params: FullCampaignPar
throw new Error(`[Step 2 - Ad Set] Falha: ${JSON.stringify(adSet)}`);
}
// Step 3: Image Uploads
const imageHashes: string[] = [];
const DATA_URI_RE = /^data:image\/[a-zA-Z0-9.+-]+;base64,(.+)$/;
const processImageUploads = async (urls?: string[], bytes?: string[]) => {
const allBytes = [...(bytes || [])];
const realUrls: string[] = [];
// gpt-image-2 returns base64 data URIs (data:image/png;base64,...) — Meta's
// upload_ad_image expects those as raw base64 via image_bytes, not image_url
// (sending a data URI as image_url causes "tipo de arquivo não suportado").
for (const url of urls || []) {
const match = DATA_URI_RE.exec(url);
if (match) {
allBytes.push(match[1]);
} else {
realUrls.push(url);
}
}
for (const url of realUrls) {
const res = await uploadAdImage(client, url, undefined, params.ad_account_id);
if ((res as any)?.hash) imageHashes.push((res as any).hash);
}
for (const b of allBytes) {
const res = await uploadAdImage(client, undefined, b, params.ad_account_id);
if ((res as any)?.hash) imageHashes.push((res as any).hash);
}
};
await processImageUploads(params.image_urls, params.image_bytes);
if (imageHashes.length === 0) {
throw new Error('[Step 3 - Upload] Nenhuma imagem foi enviada ou processada.');
}
// Step 4 & 5: Create Ad Creative and Ad
const creativeName = `${params.campaign_name} — Creative`;
// Use a single primary image via the standard object_story_spec path —
// asset_feed_spec (Asset Customization / Dynamic Creative) requires the ad
// set to be created with is_dynamic_creative=true, which we don't set here,
// and combining it with degrees_of_freedom_spec triggers Meta error subcode
// 2490433 ("unexpected error"). The first generated image (1080x1080) is
// the most broadly compatible across placements.
const primaryImageHash = imageHashes[0];
let creative;
if (params.is_whatsapp) {
creative = await createWhatsappAdCreative(client, {
name: creativeName,
// Steps 3-5: Image Upload + Ad Creative + Ad (pipeline compartilhado)
const { creative_id: creativeId, ad_id: adId } = await addAdToAdSet(client, {
adset_id: adSetId,
ad_name: params.campaign_name,
page_id: params.page_id,
instagram_actor_id: params.instagram_actor_id,
image_hash: primaryImageHash,
message: params.primary_text,
headline: params.headline,
description: params.description,
image_urls: params.image_urls,
image_bytes: params.image_bytes,
link: params.link,
headline: params.headline,
primary_text: params.primary_text,
description: params.description,
cta: params.cta,
ad_account_id: params.ad_account_id,
is_whatsapp: params.is_whatsapp,
whatsapp_link: params.whatsapp_link,
whatsapp_phone_number: params.whatsapp_phone_number,
ad_account_id: params.ad_account_id,
});
} else {
creative = await createAdCreative(client, {
name: creativeName,
page_id: params.page_id,
instagram_actor_id: params.instagram_actor_id,
image_hash: primaryImageHash,
link: params.link,
message: params.primary_text,
headline: params.headline,
description: params.description,
call_to_action_type: params.cta,
ad_account_id: params.ad_account_id,
});
}
const creativeId = (creative as any)?.id;
if (!creativeId) {
throw new Error(`[Step 4 - Creative] Falha no criativo: ${JSON.stringify(creative)}`);
}
const ad = await createAd(client, {
name: `${params.campaign_name} — Ad`,
adset_id: adSetId,
creative_id: creativeId,
ad_account_id: params.ad_account_id,
});
const adId = (ad as any)?.id;
if (!adId) {
throw new Error(`[Step 5 - Ad] Falha no anúncio: ${JSON.stringify(ad)}`);
}
return {
campaign_id: campaignId,