feat: preview de placement en pipeline de creatividades
Cada pieza muestra mock Meta/Google Ads o RRSS (feed, Stories, LinkedIn) como el Ad Tracker, con copy, media y canal editables. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
458
src/components/CreativePlacementPreview.tsx
Normal file
458
src/components/CreativePlacementPreview.tsx
Normal file
@@ -0,0 +1,458 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import {
|
||||
Facebook,
|
||||
Instagram,
|
||||
Linkedin,
|
||||
Megaphone,
|
||||
Play,
|
||||
ImageIcon,
|
||||
} from 'lucide-react';
|
||||
|
||||
export type CreativeChannel =
|
||||
| 'meta_ads'
|
||||
| 'google_ads'
|
||||
| 'instagram'
|
||||
| 'facebook'
|
||||
| 'linkedin'
|
||||
| 'tiktok'
|
||||
| 'stories'
|
||||
| 'other';
|
||||
|
||||
export type CreativeFormat =
|
||||
| 'image'
|
||||
| 'video'
|
||||
| 'carousel'
|
||||
| 'reel'
|
||||
| 'story'
|
||||
| 'rsa'
|
||||
| 'pmax'
|
||||
| 'other';
|
||||
|
||||
export type CreativePreviewData = {
|
||||
title: string;
|
||||
channel?: string | null;
|
||||
placement?: string | null;
|
||||
format?: string | null;
|
||||
headline?: string | null;
|
||||
primary_text?: string | null;
|
||||
description?: string | null;
|
||||
cta?: string | null;
|
||||
media_url?: string | null;
|
||||
brand_name?: string | null;
|
||||
landing_url?: string | null;
|
||||
};
|
||||
|
||||
function hostOf(url?: string | null): string {
|
||||
if (!url) return 'ejemplo.com';
|
||||
try {
|
||||
return new URL(url).hostname.replace(/^www\./, '');
|
||||
} catch {
|
||||
return url.replace(/^https?:\/\//, '').split('/')[0] || 'ejemplo.com';
|
||||
}
|
||||
}
|
||||
|
||||
function MediaBlock({
|
||||
data,
|
||||
tall,
|
||||
dark,
|
||||
}: {
|
||||
data: CreativePreviewData;
|
||||
tall?: boolean;
|
||||
dark?: boolean;
|
||||
}) {
|
||||
const url = data.media_url?.trim();
|
||||
const isVideo =
|
||||
(data.format || '').toLowerCase() === 'video' ||
|
||||
(data.format || '').toLowerCase() === 'reel' ||
|
||||
Boolean(url && /\.(mp4|webm|mov)(\?|$)/i.test(url));
|
||||
|
||||
if (url && isVideo) {
|
||||
return (
|
||||
<video
|
||||
src={url}
|
||||
className={`w-full object-cover ${tall ? 'h-full min-h-[220px]' : 'aspect-[4/5]'}`}
|
||||
muted
|
||||
playsInline
|
||||
controls
|
||||
preload="metadata"
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (url) {
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
return (
|
||||
<img
|
||||
src={url}
|
||||
alt={data.headline || data.title}
|
||||
className={`w-full object-cover ${tall ? 'h-full min-h-[220px]' : 'aspect-[4/5]'}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`w-full flex flex-col items-center justify-center gap-2 ${
|
||||
tall ? 'h-full min-h-[220px]' : 'aspect-[4/5]'
|
||||
} ${dark ? 'bg-gradient-to-br from-[#16204A] to-[#0B0F19]' : 'bg-gradient-to-br from-gray-800 to-gray-900'}`}
|
||||
>
|
||||
{(data.format || '').toLowerCase() === 'video' ||
|
||||
(data.format || '').toLowerCase() === 'reel' ? (
|
||||
<Play className="w-10 h-10 text-white/50" />
|
||||
) : (
|
||||
<ImageIcon className="w-10 h-10 text-white/40" />
|
||||
)}
|
||||
<p className="text-[11px] text-white/60 px-4 text-center line-clamp-3">
|
||||
{data.headline || data.title}
|
||||
</p>
|
||||
<p className="text-[10px] text-white/35 uppercase tracking-wider">
|
||||
Sin media · añade URL
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PhoneChrome({
|
||||
pill,
|
||||
children,
|
||||
stories,
|
||||
}: {
|
||||
pill: string;
|
||||
children: React.ReactNode;
|
||||
stories?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={`rounded-2xl overflow-hidden border border-gray-700 bg-black shadow-lg shadow-black/40 ${
|
||||
stories ? 'aspect-[9/16] max-h-[420px]' : ''
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between px-3 py-2 bg-[#111827] border-b border-gray-800">
|
||||
<span className="text-[10px] font-bold tracking-wide text-sky-300 uppercase">
|
||||
{pill}
|
||||
</span>
|
||||
<span className="w-8 h-1 rounded-full bg-gray-600" />
|
||||
</div>
|
||||
<div className={stories ? 'h-[calc(100%-36px)]' : ''}>{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MetaFeedPreview({ data }: { data: CreativePreviewData }) {
|
||||
const brand = data.brand_name || 'Marca';
|
||||
return (
|
||||
<PhoneChrome pill="Meta Ads · feed">
|
||||
<div className="bg-white text-gray-900">
|
||||
<div className="flex items-center gap-2 px-3 py-2">
|
||||
<div className="w-8 h-8 rounded-full bg-[#7C66B8] text-white text-[10px] font-bold flex items-center justify-center">
|
||||
{brand.slice(0, 2).toUpperCase()}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-xs font-bold truncate">{brand}</p>
|
||||
<p className="text-[10px] text-gray-500">Patrocinado · Meta</p>
|
||||
</div>
|
||||
<Megaphone className="w-3.5 h-3.5 text-gray-400" />
|
||||
</div>
|
||||
{data.primary_text ? (
|
||||
<p className="px-3 pb-2 text-[12px] leading-snug line-clamp-3">
|
||||
{data.primary_text}
|
||||
</p>
|
||||
) : null}
|
||||
<MediaBlock data={data} />
|
||||
<div className="px-3 py-2.5 border-t border-gray-100 flex items-start justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<p className="text-[10px] text-gray-500 uppercase tracking-wide truncate">
|
||||
{hostOf(data.landing_url)}
|
||||
</p>
|
||||
<p className="text-sm font-semibold line-clamp-2">
|
||||
{data.headline || data.title}
|
||||
</p>
|
||||
{data.description ? (
|
||||
<p className="text-[11px] text-gray-600 line-clamp-2 mt-0.5">
|
||||
{data.description}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<span className="shrink-0 text-[10px] font-bold px-2.5 py-1.5 rounded bg-gray-100 border border-gray-200">
|
||||
{data.cta || 'Más info'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</PhoneChrome>
|
||||
);
|
||||
}
|
||||
|
||||
function InstagramFeedPreview({ data }: { data: CreativePreviewData }) {
|
||||
const brand = data.brand_name || 'cuenta';
|
||||
return (
|
||||
<PhoneChrome pill="Instagram · feed">
|
||||
<div className="bg-white text-gray-900">
|
||||
<div className="flex items-center gap-2 px-3 py-2">
|
||||
<div className="w-7 h-7 rounded-full bg-gradient-to-tr from-amber-400 via-pink-500 to-violet-600 p-[2px]">
|
||||
<div className="w-full h-full rounded-full bg-white flex items-center justify-center text-[9px] font-bold text-pink-600">
|
||||
{brand.slice(0, 2).toUpperCase()}
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs font-bold flex-1 truncate">{brand}</p>
|
||||
<Instagram className="w-3.5 h-3.5 text-pink-500" />
|
||||
</div>
|
||||
<MediaBlock data={data} />
|
||||
<div className="px-3 py-2 space-y-1">
|
||||
<p className="text-[12px] leading-snug">
|
||||
<span className="font-bold mr-1">{brand}</span>
|
||||
<span className="line-clamp-4">
|
||||
{data.primary_text || data.headline || data.title}
|
||||
</span>
|
||||
</p>
|
||||
{data.cta ? (
|
||||
<p className="text-[11px] text-sky-600 font-semibold">{data.cta}</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</PhoneChrome>
|
||||
);
|
||||
}
|
||||
|
||||
function StoriesPreview({ data }: { data: CreativePreviewData }) {
|
||||
const brand = data.brand_name || 'cuenta';
|
||||
return (
|
||||
<PhoneChrome pill="Stories · 9:16" stories>
|
||||
<div className="relative h-full bg-black text-white">
|
||||
<div className="absolute inset-0">
|
||||
<MediaBlock data={data} tall dark />
|
||||
</div>
|
||||
<div className="absolute inset-x-0 top-0 p-3 bg-gradient-to-b from-black/70 to-transparent">
|
||||
<div className="flex gap-1 mb-2">
|
||||
<div className="h-0.5 flex-1 rounded bg-white" />
|
||||
<div className="h-0.5 flex-1 rounded bg-white/30" />
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-7 h-7 rounded-full bg-white/20 text-[9px] font-bold flex items-center justify-center">
|
||||
{brand.slice(0, 2).toUpperCase()}
|
||||
</div>
|
||||
<p className="text-xs font-bold">{brand}</p>
|
||||
<span className="text-[10px] text-white/60">ahora</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="absolute inset-x-0 bottom-0 p-4 bg-gradient-to-t from-black/80 to-transparent space-y-2">
|
||||
<p className="text-sm font-semibold line-clamp-3">
|
||||
{data.headline || data.primary_text || data.title}
|
||||
</p>
|
||||
{data.cta ? (
|
||||
<span className="inline-block text-[11px] font-bold px-3 py-1.5 rounded-full bg-white text-black">
|
||||
{data.cta}
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-block text-[10px] text-white/70">
|
||||
Sticker / encuesta
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</PhoneChrome>
|
||||
);
|
||||
}
|
||||
|
||||
function LinkedInPreview({ data }: { data: CreativePreviewData }) {
|
||||
const brand = data.brand_name || 'Empresa';
|
||||
return (
|
||||
<PhoneChrome pill="LinkedIn · feed">
|
||||
<div className="bg-white text-gray-900">
|
||||
<div className="flex items-start gap-2 px-3 py-2.5">
|
||||
<div className="w-9 h-9 rounded bg-[#0A66C2] text-white text-[10px] font-bold flex items-center justify-center">
|
||||
{brand.slice(0, 2).toUpperCase()}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-xs font-bold truncate">{brand}</p>
|
||||
<p className="text-[10px] text-gray-500">Promocionado · LinkedIn</p>
|
||||
</div>
|
||||
<Linkedin className="w-3.5 h-3.5 text-[#0A66C2]" />
|
||||
</div>
|
||||
<p className="px-3 pb-2 text-[12px] leading-snug whitespace-pre-wrap line-clamp-5">
|
||||
{data.primary_text || data.headline || data.title}
|
||||
</p>
|
||||
{(data.media_url || data.format === 'image' || data.format === 'video') && (
|
||||
<MediaBlock data={data} />
|
||||
)}
|
||||
<div className="px-3 py-2 border-t border-gray-100 flex items-center justify-between">
|
||||
<p className="text-[11px] text-gray-500 truncate">
|
||||
{data.headline || 'Ver más'}
|
||||
</p>
|
||||
<span className="text-[10px] font-bold text-[#0A66C2]">
|
||||
{data.cta || 'Saber más'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</PhoneChrome>
|
||||
);
|
||||
}
|
||||
|
||||
function FacebookFeedPreview({ data }: { data: CreativePreviewData }) {
|
||||
const brand = data.brand_name || 'Página';
|
||||
return (
|
||||
<PhoneChrome pill="Facebook · feed">
|
||||
<div className="bg-white text-gray-900">
|
||||
<div className="flex items-center gap-2 px-3 py-2">
|
||||
<div className="w-8 h-8 rounded-full bg-[#1877F2] text-white text-[10px] font-bold flex items-center justify-center">
|
||||
{brand.slice(0, 1).toUpperCase()}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-xs font-bold truncate">{brand}</p>
|
||||
<p className="text-[10px] text-gray-500">Patrocinado</p>
|
||||
</div>
|
||||
<Facebook className="w-3.5 h-3.5 text-[#1877F2]" />
|
||||
</div>
|
||||
{data.primary_text ? (
|
||||
<p className="px-3 pb-2 text-[12px] leading-snug line-clamp-3">
|
||||
{data.primary_text}
|
||||
</p>
|
||||
) : null}
|
||||
<MediaBlock data={data} />
|
||||
<div className="px-3 py-2 bg-gray-50 border-t border-gray-100 flex justify-between gap-2 items-center">
|
||||
<div className="min-w-0">
|
||||
<p className="text-[10px] text-gray-500 uppercase truncate">
|
||||
{hostOf(data.landing_url)}
|
||||
</p>
|
||||
<p className="text-sm font-semibold line-clamp-2">
|
||||
{data.headline || data.title}
|
||||
</p>
|
||||
</div>
|
||||
<span className="shrink-0 text-[10px] font-bold px-2.5 py-1.5 rounded bg-gray-200">
|
||||
{data.cta || 'Más información'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</PhoneChrome>
|
||||
);
|
||||
}
|
||||
|
||||
function GoogleSearchPreview({ data }: { data: CreativePreviewData }) {
|
||||
const host = hostOf(data.landing_url);
|
||||
return (
|
||||
<div className="rounded-2xl border border-gray-700 bg-white text-gray-900 p-4 shadow-lg">
|
||||
<p className="text-[10px] font-bold uppercase tracking-wider text-sky-600 mb-2">
|
||||
Google Ads · búsqueda
|
||||
</p>
|
||||
<p className="text-[11px] text-emerald-800">
|
||||
Anuncio · <span className="text-gray-600">{host}</span>
|
||||
</p>
|
||||
<p className="text-lg text-[#1a0dab] font-medium leading-snug mt-0.5">
|
||||
{data.headline || data.title}
|
||||
</p>
|
||||
<p className="text-[13px] text-gray-700 mt-1 line-clamp-3">
|
||||
{data.description || data.primary_text || 'Descripción del anuncio…'}
|
||||
</p>
|
||||
{data.cta ? (
|
||||
<p className="text-[11px] text-sky-700 mt-2 font-semibold">{data.cta}</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function GoogleDisplayPreview({ data }: { data: CreativePreviewData }) {
|
||||
return (
|
||||
<div className="rounded-2xl border border-gray-700 bg-[#111827] overflow-hidden">
|
||||
<p className="text-[10px] font-bold uppercase tracking-wider text-sky-400 px-3 py-2 border-b border-gray-800">
|
||||
Google Ads · display / PMax
|
||||
</p>
|
||||
<MediaBlock data={data} />
|
||||
<div className="p-3 bg-white text-gray-900">
|
||||
<p className="text-sm font-bold line-clamp-2">
|
||||
{data.headline || data.title}
|
||||
</p>
|
||||
<p className="text-[11px] text-gray-600 line-clamp-2 mt-1">
|
||||
{data.description || data.primary_text}
|
||||
</p>
|
||||
<p className="text-[10px] text-gray-400 mt-2">{hostOf(data.landing_url)}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Renders a placement mock like Ad Tracker phone / Google RSA cards. */
|
||||
export default function CreativePlacementPreview({
|
||||
data,
|
||||
compact,
|
||||
}: {
|
||||
data: CreativePreviewData;
|
||||
compact?: boolean;
|
||||
}) {
|
||||
const channel = (data.channel || '').toLowerCase();
|
||||
const placement = (data.placement || '').toLowerCase();
|
||||
const format = (data.format || '').toLowerCase();
|
||||
|
||||
let body: React.ReactNode;
|
||||
|
||||
if (channel === 'google_ads' || format === 'rsa') {
|
||||
body =
|
||||
format === 'pmax' || placement === 'display' ? (
|
||||
<GoogleDisplayPreview data={data} />
|
||||
) : (
|
||||
<GoogleSearchPreview data={data} />
|
||||
);
|
||||
} else if (
|
||||
channel === 'stories' ||
|
||||
placement === 'stories' ||
|
||||
format === 'story'
|
||||
) {
|
||||
body = <StoriesPreview data={data} />;
|
||||
} else if (channel === 'linkedin') {
|
||||
body = <LinkedInPreview data={data} />;
|
||||
} else if (channel === 'instagram') {
|
||||
body =
|
||||
placement === 'reels' || format === 'reel' ? (
|
||||
<StoriesPreview data={data} />
|
||||
) : (
|
||||
<InstagramFeedPreview data={data} />
|
||||
);
|
||||
} else if (channel === 'facebook') {
|
||||
body = <FacebookFeedPreview data={data} />;
|
||||
} else if (channel === 'meta_ads' || channel === 'tiktok' || !channel) {
|
||||
body =
|
||||
placement === 'stories' || format === 'story' ? (
|
||||
<StoriesPreview data={data} />
|
||||
) : (
|
||||
<MetaFeedPreview data={data} />
|
||||
);
|
||||
} else {
|
||||
body = <MetaFeedPreview data={data} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={compact ? 'max-w-[280px]' : 'w-full max-w-[320px]'}>
|
||||
{body}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const CHANNEL_OPTIONS: { id: CreativeChannel; label: string }[] = [
|
||||
{ id: 'meta_ads', label: 'Meta Ads' },
|
||||
{ id: 'google_ads', label: 'Google Ads' },
|
||||
{ id: 'instagram', label: 'Instagram (orgánico)' },
|
||||
{ id: 'facebook', label: 'Facebook (orgánico)' },
|
||||
{ id: 'linkedin', label: 'LinkedIn' },
|
||||
{ id: 'stories', label: 'Stories' },
|
||||
{ id: 'tiktok', label: 'TikTok' },
|
||||
{ id: 'other', label: 'Otro' },
|
||||
];
|
||||
|
||||
export const FORMAT_OPTIONS: { id: CreativeFormat; label: string }[] = [
|
||||
{ id: 'image', label: 'Imagen' },
|
||||
{ id: 'video', label: 'Vídeo' },
|
||||
{ id: 'carousel', label: 'Carrusel' },
|
||||
{ id: 'reel', label: 'Reel' },
|
||||
{ id: 'story', label: 'Story' },
|
||||
{ id: 'rsa', label: 'RSA (búsqueda)' },
|
||||
{ id: 'pmax', label: 'PMax / Display' },
|
||||
{ id: 'other', label: 'Otro' },
|
||||
];
|
||||
|
||||
export const PLACEMENT_OPTIONS = [
|
||||
{ id: 'feed', label: 'Feed' },
|
||||
{ id: 'stories', label: 'Stories' },
|
||||
{ id: 'reels', label: 'Reels' },
|
||||
{ id: 'search', label: 'Búsqueda' },
|
||||
{ id: 'display', label: 'Display' },
|
||||
{ id: 'other', label: 'Otro' },
|
||||
];
|
||||
Reference in New Issue
Block a user