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:
parent
f47c9e58ce
commit
d2406f0152
7 changed files with 849 additions and 99 deletions
153
src/app/api/adsets/[id]/ads/route.ts
Normal file
153
src/app/api/adsets/[id]/ads/route.ts
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
32
src/app/api/adsets/[id]/creative-defaults/route.ts
Normal file
32
src/app/api/adsets/[id]/creative-defaults/route.ts
Normal 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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -194,6 +194,7 @@ export default function DashboardPage() {
|
||||||
loading={loading}
|
loading={loading}
|
||||||
onStatusChange={handleStatusChange}
|
onStatusChange={handleStatusChange}
|
||||||
onBudgetChange={handleBudgetChange}
|
onBudgetChange={handleBudgetChange}
|
||||||
|
onAdCreated={() => fetchData(false)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Footer */}
|
{/* Footer */}
|
||||||
|
|
|
||||||
371
src/components/dashboard/AddAdModal.tsx
Normal file
371
src/components/dashboard/AddAdModal.tsx
Normal 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 já 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,9 +1,10 @@
|
||||||
'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 } 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 { Spinner } from '@/components/ui/Spinner';
|
||||||
import { BudgetModal } from './BudgetModal';
|
import { BudgetModal } from './BudgetModal';
|
||||||
|
import { AddAdModal } from './AddAdModal';
|
||||||
import type { CampaignWithInsights } from '@/lib/meta/types';
|
import type { CampaignWithInsights } from '@/lib/meta/types';
|
||||||
|
|
||||||
interface CampaignTableProps {
|
interface CampaignTableProps {
|
||||||
|
|
@ -11,6 +12,7 @@ interface CampaignTableProps {
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
onStatusChange: (id: string, s: 'ACTIVE' | 'PAUSED') => Promise<void>;
|
onStatusChange: (id: string, s: 'ACTIVE' | 'PAUSED') => Promise<void>;
|
||||||
onBudgetChange: (id: string, b: number) => Promise<void>;
|
onBudgetChange: (id: string, b: number) => Promise<void>;
|
||||||
|
onAdCreated: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
type SortField = 'name' | 'status' | 'spend' | 'ctr' | 'cpc' | 'cpm' | 'leads' | 'roas';
|
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;
|
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 [search, setSearch] = useState('');
|
||||||
const [sortField, setSortField] = useState<SortField>('spend');
|
const [sortField, setSortField] = useState<SortField>('spend');
|
||||||
const [sortDir, setSortDir] = useState<'asc' | 'desc'>('desc');
|
const [sortDir, setSortDir] = useState<'asc' | 'desc'>('desc');
|
||||||
const [actionLoading, setActionLoading] = useState<string | null>(null);
|
const [actionLoading, setActionLoading] = useState<string | null>(null);
|
||||||
const [budgetModal, setBudgetModal] = useState<{ campaignId: string; campaignName: string; currentBudget: number } | 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
|
// 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());
|
||||||
|
|
@ -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?.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="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">
|
<td className="pr-5 py-2">
|
||||||
{/* Simple status toggle for adset */}
|
<div className="flex items-center gap-1">
|
||||||
<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
|
||||||
{actionLoading === adset.id ? <Spinner size="sm" /> : adsetActive ? <Pause size={12} /> : <Play size={12} />}
|
onClick={() => setAddAdModal({
|
||||||
</button>
|
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>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|
||||||
|
|
@ -260,6 +281,17 @@ export function CampaignTable({ campaigns, loading, onStatusChange, onBudgetChan
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{budgetModal && <BudgetModal isOpen={!!budgetModal} onClose={() => setBudgetModal(null)} campaignId={budgetModal.campaignId} campaignName={budgetModal.campaignName} currentBudget={budgetModal.currentBudget} onSave={onBudgetChange} />}
|
{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}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -139,6 +139,14 @@ export async function callMetaTool(
|
||||||
});
|
});
|
||||||
return { content: [{ type: 'text', text: JSON.stringify(resultData.data?.[0] || {}) }] };
|
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': {
|
case 'get_campaign_details': {
|
||||||
requireFields(args, ['campaign_id'], toolName);
|
requireFields(args, ['campaign_id'], toolName);
|
||||||
const preset = args.date_preset || 'last_30d';
|
const preset = args.date_preset || 'last_30d';
|
||||||
|
|
|
||||||
|
|
@ -243,6 +243,106 @@ export async function resumeAdSet(client: Client, adSetId: string) {
|
||||||
return extractContent(result);
|
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 já
|
||||||
|
* 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
|
// Campaign Creation
|
||||||
// ==============================
|
// ==============================
|
||||||
|
|
@ -417,6 +517,136 @@ export async function uploadAdImage(
|
||||||
return extractContent(result);
|
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 já 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
|
// Full Campaign Creation Pipeline
|
||||||
// ==============================
|
// ==============================
|
||||||
|
|
@ -504,102 +734,25 @@ export async function createFullCampaign(client: Client, params: FullCampaignPar
|
||||||
throw new Error(`[Step 2 - Ad Set] Falha: ${JSON.stringify(adSet)}`);
|
throw new Error(`[Step 2 - Ad Set] Falha: ${JSON.stringify(adSet)}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 3: Image Uploads
|
// Steps 3-5: Image Upload + Ad Creative + Ad (pipeline compartilhado)
|
||||||
const imageHashes: string[] = [];
|
const { creative_id: creativeId, ad_id: adId } = await addAdToAdSet(client, {
|
||||||
|
|
||||||
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,
|
|
||||||
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(`[Step 4 - Creative] Falha no criativo: ${JSON.stringify(creative)}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const ad = await createAd(client, {
|
|
||||||
name: `${params.campaign_name} — Ad`,
|
|
||||||
adset_id: adSetId,
|
adset_id: adSetId,
|
||||||
creative_id: creativeId,
|
ad_name: params.campaign_name,
|
||||||
|
page_id: params.page_id,
|
||||||
|
instagram_actor_id: params.instagram_actor_id,
|
||||||
|
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,
|
ad_account_id: params.ad_account_id,
|
||||||
|
is_whatsapp: params.is_whatsapp,
|
||||||
|
whatsapp_link: params.whatsapp_link,
|
||||||
|
whatsapp_phone_number: params.whatsapp_phone_number,
|
||||||
});
|
});
|
||||||
|
|
||||||
const adId = (ad as any)?.id;
|
|
||||||
if (!adId) {
|
|
||||||
throw new Error(`[Step 5 - Ad] Falha no anúncio: ${JSON.stringify(ad)}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
campaign_id: campaignId,
|
campaign_id: campaignId,
|
||||||
adset_id: adSetId,
|
adset_id: adSetId,
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue