diff --git a/src/app/api/adsets/[id]/ads/route.ts b/src/app/api/adsets/[id]/ads/route.ts new file mode 100644 index 0000000..794777f --- /dev/null +++ b/src/app/api/adsets/[id]/ads/route.ts @@ -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) { + 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> | 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(); + } + } +} diff --git a/src/app/api/adsets/[id]/creative-defaults/route.ts b/src/app/api/adsets/[id]/creative-defaults/route.ts new file mode 100644 index 0000000..14bc873 --- /dev/null +++ b/src/app/api/adsets/[id]/creative-defaults/route.ts @@ -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 } + ); + } +} diff --git a/src/app/dashboard/page.tsx b/src/app/dashboard/page.tsx index a707cbf..d753919 100644 --- a/src/app/dashboard/page.tsx +++ b/src/app/dashboard/page.tsx @@ -194,6 +194,7 @@ export default function DashboardPage() { loading={loading} onStatusChange={handleStatusChange} onBudgetChange={handleBudgetChange} + onAdCreated={() => fetchData(false)} /> {/* Footer */} diff --git a/src/components/dashboard/AddAdModal.tsx b/src/components/dashboard/AddAdModal.tsx new file mode 100644 index 0000000..fb9db51 --- /dev/null +++ b/src/components/dashboard/AddAdModal.tsx @@ -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(null); + const [defaultsLoading, setDefaultsLoading] = useState(false); + + const [copyLoading, setCopyLoading] = useState(false); + const [copyVariations, setCopyVariations] = useState([]); + const [selectedCopy, setSelectedCopy] = useState(null); + + const [imageLoading, setImageLoading] = useState(false); + const [generatedImages, setGeneratedImages] = useState([]); + const [selectedImage, setSelectedImage] = useState(null); + const [uploadedImage, setUploadedImage] = useState(null); + + const [publishing, setPublishing] = useState(false); + const [error, setError] = useState(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) => { + 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 ( + +
+

+ Campanha: {campaignName} + {' · '} + Ad Set: {adsetName} +

+

+ 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. +

+ + {error && } + + {done ? ( +
+ +

Anúncio publicado com sucesso!

+

+ Ele foi criado como Pausado — ative-o no Ads Manager (ou na tabela) quando estiver pronto. +

+ +
+ ) : ( + <> +
+ +