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}
|
||||
onStatusChange={handleStatusChange}
|
||||
onBudgetChange={handleBudgetChange}
|
||||
onAdCreated={() => fetchData(false)}
|
||||
/>
|
||||
|
||||
{/* 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';
|
||||
|
||||
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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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 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
|
||||
// ==============================
|
||||
|
|
@ -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 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
|
||||
// ==============================
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in a new issue