Initial commit — Bradly branding editor platform

This commit is contained in:
2026-06-02 03:27:03 -05:00
commit b135a70cc7
180 changed files with 43160 additions and 0 deletions
@@ -0,0 +1,473 @@
import React, { useRef, useCallback, useMemo } from 'react';
import {
Type, Image as ImageIcon, Video, Pentagon, Zap, Move, Maximize2,
Upload, Film,
} from 'lucide-react';
import { TemplateField, TemplateFieldNature, DesignMD, CompanyProfile, StickerConfig } from '../../../types';
import { getAspectDimensions } from '../../../utils/expressCompiler';
import { useDragResize } from '../../../hooks/useDragResize';
import { useTemplateBuilder } from '../../../context/TemplateBuilderContext';
import { getPlatformIcon, isSocialSource, DEFAULT_STICKER } from './PlatformIcons';
import { resolveBrandRole } from '../../ui/FieldInspector';
import { SegmentVideoFrame } from './SegmentVideoFrame';
/** Get type icon */
function getTypeIcon(type: TemplateField['type'], size = 14): React.ReactNode {
switch (type) {
case 'text': return <Type size={size} />;
case 'image': return <ImageIcon size={size} />;
case 'video': return <Video size={size} />;
case 'shape': return <Pentagon size={size} />;
case 'sticker': return <Zap size={size} />;
}
}
/** Nature colors */
const NATURE_COLORS: Record<TemplateFieldNature, { border: string; bg: string; text: string; badge: string }> = {
'static': {
border: 'rgba(107, 114, 128, 0.4)',
bg: 'rgba(107, 114, 128, 0.08)',
text: '#9ca3af',
badge: '#6b7280',
},
'brand-variable': {
border: 'rgba(167, 139, 250, 0.5)',
bg: 'rgba(167, 139, 250, 0.08)',
text: '#c4b5fd',
badge: '#a78bfa',
},
'editable-slot': {
border: 'rgba(56, 189, 248, 0.5)',
bg: 'rgba(56, 189, 248, 0.06)',
text: '#7dd3fc',
badge: '#38bdf8',
},
};
/** Resolve brand variable to preview text */
function resolveBrandPreview(field: TemplateField, designMD: DesignMD, company: CompanyProfile): string {
if (field.nature !== 'brand-variable' || !field.brandSource) return field.content;
switch (field.brandSource) {
case 'brand-name': return company.name || designMD.brandName || 'Tu Marca';
case 'tagline': return company.tagline || 'Tu eslogan';
case 'logo': return designMD.logoUrl || '';
case 'instagram': return company.socialLinks?.instagram || '@instagram';
case 'tiktok': return company.socialLinks?.tiktok || '@tiktok';
case 'twitter': return company.socialLinks?.x || '@x';
case 'youtube': return company.socialLinks?.youtube || 'YouTube';
case 'website': return company.socialLinks?.website || 'www.example.com';
default: return field.content;
}
}
/**
* BuilderCanvas — Interactive canvas for the Template Builder.
*
* Renders TemplateField[] directly with visual differentiation by nature:
* - static: solid border, rendered content, no badge
* - brand-variable: dotted violet border, real preview data, "auto" badge
* - editable-slot: dashed blue border, placeholder zone with icon + label, "campo" badge
*
* Uses the shared `useDragResize` hook for all pointer interactions (per AGENTS.md).
*/
export const BuilderCanvas: React.FC = () => {
const {
fields,
updateField,
selectedFieldId,
setSelectedFieldId,
designMD,
company,
templateMeta,
activeScene,
updateSegment,
previewBrand,
} = useTemplateBuilder();
// Detect segment mode: active scene is an intro/outro with segmentSource
const isSegmentMode = !!(activeScene?.segmentSource);
const containerRef = useRef<HTMLDivElement>(null);
// ── Shared drag/resize hook ──
const {
startDrag,
startResize,
handlePointerMove,
handlePointerUp,
isDragging,
activeId: dragFieldId,
snapGuides,
} = useDragResize({
containerRef: containerRef as React.RefObject<HTMLElement>,
onMove: useCallback((id: string, x: number, y: number) => {
const field = fields.find(f => f.id === id);
if (!field) return;
updateField(id, { position: { ...field.position, x, y } });
}, [fields, updateField]),
onResize: useCallback((id: string, w: number, h: number) => {
const field = fields.find(f => f.id === id);
if (!field) return;
updateField(id, { position: { ...field.position, w, h } });
}, [fields, updateField]),
snapLines: [50],
snapThreshold: 1.5,
});
const dimensions = getAspectDimensions(templateMeta.aspectRatio);
// Resolve background
const bgColor = useMemo(() => {
const bg = activeScene?.background;
if (!bg) return designMD.secondaryColor;
switch (bg.type) {
case 'brand': return designMD.secondaryColor;
case 'solid': return bg.value || '#1a1a1a';
case 'gradient': return undefined;
case 'media': return '#111';
default: return designMD.secondaryColor;
}
}, [activeScene, designMD]);
const bgGradient = activeScene?.background?.type === 'gradient'
? `linear-gradient(135deg, ${designMD.primaryColor} 0%, ${designMD.secondaryColor} 100%)`
: undefined;
const handleCanvasClick = useCallback(() => {
if (!isDragging) setSelectedFieldId(null);
}, [isDragging, setSelectedFieldId]);
// In segment mode, render SegmentVideoFrame instead of normal fields
if (isSegmentMode && activeScene) {
return (
<SegmentVideoFrame
scene={activeScene}
designMD={designMD}
previewBrand={previewBrand}
aspectRatio={templateMeta.aspectRatio}
onPositionChange={(updates) => updateSegment(activeScene.id, updates)}
/>
);
}
return (
<div className="flex-1 flex flex-col items-center justify-center bg-neutral-950 p-4 overflow-hidden relative min-h-0">
{/* Dot pattern */}
<div
className="absolute inset-0 opacity-[0.02]"
style={{ backgroundImage: 'radial-gradient(circle at 1px 1px, white 1px, transparent 0)', backgroundSize: '30px 30px' }}
/>
{/* Canvas wrapper */}
<div
ref={containerRef}
className="relative rounded-xl overflow-hidden shadow-2xl shadow-black/50 border border-neutral-800/40 select-none shrink-0"
style={{
...(templateMeta.aspectRatio === '9:16' || templateMeta.aspectRatio === '4:5'
? { height: 'calc(100% - 40px)', maxWidth: '90%' }
: {
width: templateMeta.aspectRatio === '1:1' ? 360 : 440,
maxHeight: 'calc(100% - 40px)',
}),
aspectRatio: `${dimensions.w} / ${dimensions.h}`,
backgroundColor: bgColor,
backgroundImage: bgGradient,
}}
onPointerDown={handleCanvasClick}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
onPointerCancel={handlePointerUp}
>
{/* Snap guides */}
{snapGuides.x !== undefined && (
<div
className="absolute top-0 bottom-0 pointer-events-none z-50"
style={{ left: `${snapGuides.x}%`, width: '1px', background: 'rgba(139, 92, 246, 0.5)', borderLeft: '1px dashed rgba(139, 92, 246, 0.6)' }}
/>
)}
{snapGuides.y !== undefined && (
<div
className="absolute left-0 right-0 pointer-events-none z-50"
style={{ top: `${snapGuides.y}%`, height: '1px', background: 'rgba(139, 92, 246, 0.5)', borderTop: '1px dashed rgba(139, 92, 246, 0.6)' }}
/>
)}
{/* Center crosshair (subtle) */}
<div className="absolute left-1/2 top-0 bottom-0 w-px bg-white/[0.03] pointer-events-none z-0" />
<div className="absolute top-1/2 left-0 right-0 h-px bg-white/[0.03] pointer-events-none z-0" />
{/* ── Render fields ── */}
{fields.map((field, idx) => {
// Skip hidden layers
if (field.visible === false) return null;
const isSelected = selectedFieldId === field.id;
const isDraggingField = dragFieldId === field.id;
const isLocked = field.locked === true;
const colors = NATURE_COLORS[field.nature];
return (
<div
key={field.id}
className="absolute transition-shadow"
style={{
left: `${field.position.x - field.position.w / 2}%`,
top: `${field.position.y - field.position.h / 2}%`,
width: `${field.position.w}%`,
height: `${field.position.h}%`,
transform: field.position.rotation ? `rotate(${field.position.rotation}deg)` : undefined,
// z-index from array position: index 0 = back, last = front
// Dragging/selected get temporary boost to stay on top during interaction
zIndex: isDraggingField ? 1000 : isSelected ? 999 : idx + 1,
}}
>
{/* Field box */}
<div
className={`w-full h-full rounded-md flex flex-col items-center justify-center gap-0.5 transition-all ${
isLocked ? 'cursor-default' : 'cursor-grab active:cursor-grabbing'
} ${isDraggingField ? 'scale-[1.02] shadow-xl' : ''}`}
style={{
backgroundColor: isSelected ? `${colors.badge}20` : colors.bg,
border: `${field.nature === 'editable-slot' ? '2px dashed' : field.nature === 'brand-variable' ? '1px dotted' : '1px solid'} ${
isSelected ? colors.badge : colors.border
}`,
outline: isSelected ? `2px solid ${colors.badge}60` : undefined,
outlineOffset: isSelected ? '2px' : undefined,
}}
onPointerDown={(e) => {
e.stopPropagation();
if (isLocked) return; // Can't interact with locked layers
setSelectedFieldId(field.id);
startDrag(e, field.id, field.position);
}}
>
{/* ── Nature-specific content ── */}
{field.nature === 'static' && (
<StaticFieldContent field={field} designMD={designMD} />
)}
{field.nature === 'brand-variable' && (
<BrandVariableContent field={field} designMD={designMD} company={company} />
)}
{field.nature === 'editable-slot' && (
<EditableSlotContent field={field} />
)}
{/* ── Badge (brand-variable and editable-slot) ── */}
{field.nature !== 'static' && (
<div
className="absolute -top-2.5 left-2 flex items-center gap-0.5 px-1.5 py-0.5 rounded text-[7px] font-bold tracking-wider pointer-events-none"
style={{
backgroundColor: `${colors.badge}20`,
color: colors.badge,
border: `1px solid ${colors.badge}40`,
}}
>
{field.nature === 'brand-variable' ? (
<><Zap size={7} /> auto</>
) : (
<>{getTypeIcon(field.type, 7)} {field.label}</>
)}
</div>
)}
{/* Position readout when selected */}
{isSelected && (
<div className="absolute -bottom-5 left-1/2 -translate-x-1/2 flex items-center gap-1 text-[7px] text-violet-300/60 font-mono whitespace-nowrap pointer-events-none">
<Move size={7} /> {field.position.x.toFixed(0)},{field.position.y.toFixed(0)}
<Maximize2 size={7} className="ml-1" /> {field.position.w.toFixed(0)}×{field.position.h.toFixed(0)}
</div>
)}
</div>
{/* Resize handle */}
{isSelected && (
<div
className="absolute -bottom-1.5 -right-1.5 w-3 h-3 border-2 border-neutral-900 rounded-sm cursor-nwse-resize z-40 hover:opacity-80 transition-colors"
style={{ backgroundColor: colors.badge }}
onPointerDown={(e) => startResize(e, field.id, field.position)}
title="Redimensionar campo"
/>
)}
</div>
);
})}
{/* Empty state */}
{fields.length === 0 && (
<div className="absolute inset-0 flex items-center justify-center">
<p className="text-[10px] text-white/20 text-center">
Agrega campos desde el panel izquierdo<br />
para posicionarlos aquí
</p>
</div>
)}
</div>
</div>
);
};
// ═══ Nature-specific content renderers ═══
/** Static: show actual content/shape */
const StaticFieldContent: React.FC<{ field: TemplateField; designMD: DesignMD }> = ({ field, designMD }) => {
if (field.type === 'text') {
// Resolve brand typography if useBrandStyle is active
const brandStyle = (field.style.useBrandStyle !== false && field.style.textRole)
? resolveBrandRole(designMD, field.style.textRole)
: null;
return (
<span
className="pointer-events-none text-center px-1 truncate w-full"
style={{
fontSize: `${Math.min(brandStyle?.fontSize || field.style.fontSize || 16, 20)}px`,
fontWeight: brandStyle?.fontWeight || field.style.fontWeight || 400,
fontFamily: brandStyle?.fontFamily || field.style.fontFamily || designMD.baseFont,
color: brandStyle?.color || field.style.color || designMD.textColor || '#ffffff',
opacity: (field.style.opacity ?? 100) / 100,
}}
>
{field.content || 'Texto estático'}
</span>
);
}
if (field.type === 'shape') {
return (
<div
className="w-full h-full rounded pointer-events-none"
style={{
backgroundColor: field.style.shapeFill || designMD.primaryColor,
borderRadius: field.style.shapeCornerRadius ? `${field.style.shapeCornerRadius}px` : undefined,
opacity: (field.style.opacity ?? 100) / 100,
}}
/>
);
}
// image or video static
return (
<div className="flex flex-col items-center justify-center gap-0.5 pointer-events-none" style={{ color: '#6b7280' }}>
{getTypeIcon(field.type, 16)}
<span className="text-[7px] font-mono">{field.type}</span>
</div>
);
};
/** Brand variable: show real preview with brand styling */
const BrandVariableContent: React.FC<{ field: TemplateField; designMD: DesignMD; company: CompanyProfile }> = ({ field, designMD, company }) => {
const preview = resolveBrandPreview(field, designMD, company);
// Logo: show image
if (field.brandSource === 'logo' && designMD.logoUrl) {
return (
<img
src={designMD.logoUrl}
alt="Logo"
className="max-w-full max-h-full object-contain pointer-events-none p-1"
style={{ opacity: (field.style.opacity ?? 100) / 100 }}
/>
);
}
// Sticker: icon + text composite
if (field.type === 'sticker') {
return <BrandStickerContent field={field} designMD={designMD} company={company} />;
}
// Text brand variable: show with brand font
const brandStyle = (field.style.useBrandStyle !== false && field.style.textRole)
? resolveBrandRole(designMD, field.style.textRole)
: null;
return (
<span
className="pointer-events-none text-center px-1 truncate w-full"
style={{
fontSize: `${Math.min(brandStyle?.fontSize || field.style.fontSize || 16, 18)}px`,
fontWeight: brandStyle?.fontWeight || field.style.fontWeight || 400,
fontFamily: brandStyle?.fontFamily || field.style.fontFamily || designMD.baseFont,
color: brandStyle?.color || field.style.color || '#c4b5fd',
opacity: (field.style.opacity ?? 100) / 100,
}}
>
{preview}
</span>
);
};
/** Brand Sticker: icon + text as a single visual unit */
const BrandStickerContent: React.FC<{ field: TemplateField; designMD: DesignMD; company: CompanyProfile }> = ({ field, designMD, company }) => {
const sticker: StickerConfig = field.style.sticker || DEFAULT_STICKER;
const rawValue = resolveBrandPreview(field, designMD, company);
// Format display text
const displayText = sticker.showAtPrefix && isSocialSource(field.brandSource) && field.brandSource !== 'website'
? `@${rawValue.replace(/^@/, '')}`
: field.brandSource === 'website'
? rawValue.replace(/^https?:\/\//, '').replace(/\/$/, '')
: rawValue;
const iconSize = Math.min(Math.max((field.style.fontSize || 14) * 0.9, 10), 18);
const icon = sticker.showIcon ? getPlatformIcon(field.brandSource, iconSize) : null;
const isPill = sticker.stickerStyle === 'pill';
return (
<div
className={`flex items-center pointer-events-none w-full h-full justify-center ${
isPill ? 'px-2' : 'px-1'
}`}
>
<div
className={`flex items-center ${
isPill ? 'bg-white/10 rounded-full px-3 py-1' : ''
}`}
style={{
gap: `${sticker.gap}px`,
flexDirection: sticker.iconPosition === 'right' ? 'row-reverse' : 'row',
}}
>
{icon && (
<span
className="shrink-0 flex items-center justify-center"
style={{ color: sticker.iconColor || designMD.primaryColor }}
>
{icon}
</span>
)}
<span
className="truncate"
style={{
fontSize: `${Math.min(field.style.fontSize || 14, 16)}px`,
fontWeight: field.style.fontWeight || 500,
fontFamily: field.style.fontFamily || designMD.baseFont,
color: field.style.color || '#c4b5fd',
opacity: (field.style.opacity ?? 100) / 100,
}}
>
{displayText}
</span>
</div>
</div>
);
};
/** Editable slot: show placeholder zone */
const EditableSlotContent: React.FC<{ field: TemplateField }> = ({ field }) => {
return (
<div className="flex flex-col items-center justify-center gap-1 pointer-events-none w-full h-full">
<div style={{ color: '#7dd3fc' }}>
{field.type === 'text' && <Type size={16} />}
{field.type === 'image' && <Upload size={16} />}
{field.type === 'video' && <Film size={16} />}
{field.type === 'shape' && <Pentagon size={16} />}
</div>
<span className="text-[8px] text-sky-300/60 font-medium truncate max-w-[90%] text-center">
{field.label}
</span>
{field.required && (
<span className="text-[6px] text-red-400/60 font-bold">OBLIGATORIO</span>
)}
</div>
);
};
@@ -0,0 +1,321 @@
import React from 'react';
import { X, Type, Image as ImageIcon, Plus, Trash2, Zap, Clock, Layers, Sparkles, Globe, Instagram, AtSign } from 'lucide-react';
import { ExpressScene, ExpressField, SceneLayout, BrandContentPiece, DesignMD, TimelineElement } from '../../../types';
import { useEditor } from '../../../context/EditorContext';
import { CollapsibleSection } from '../../ui/CollapsibleSection';
interface BuilderScenePanelProps {
onClose: () => void;
scene: ExpressScene;
onUpdateScene: (updated: ExpressScene) => void;
brandContent: BrandContentPiece[];
designMD: DesignMD;
isVideo: boolean;
}
/** Layout options */
const LAYOUTS: { value: SceneLayout; label: string; icon: string }[] = [
{ value: 'fullscreen-media', label: 'Pantalla completa', icon: '📸' },
{ value: 'overlay', label: 'Overlay', icon: '🔲' },
{ value: 'split', label: 'Dividido', icon: '◫' },
{ value: 'media-left', label: 'Media izq.', icon: '◧' },
{ value: 'media-right', label: 'Media der.', icon: '◨' },
{ value: 'text-only', label: 'Solo texto', icon: '📝' },
];
/** Brand variables available for insertion */
const BRAND_VARIABLES: { source: ExpressField['brandSource']; label: string; icon: React.ReactNode; type: ExpressField['type'] }[] = [
{ source: 'brand-name', label: 'Nombre de Marca', icon: <Type size={10} />, type: 'text' },
{ source: 'tagline', label: 'Tagline / Eslogan', icon: <Sparkles size={10} />, type: 'text' },
{ source: 'logo', label: 'Logo', icon: <Zap size={10} />, type: 'logo' },
{ source: 'instagram', label: 'Instagram', icon: <Instagram size={10} />, type: 'text' },
{ source: 'tiktok', label: 'TikTok', icon: <AtSign size={10} />, type: 'text' },
{ source: 'twitter', label: 'X / Twitter', icon: <AtSign size={10} />, type: 'text' },
{ source: 'youtube', label: 'YouTube', icon: <AtSign size={10} />, type: 'text' },
{ source: 'website', label: 'Website', icon: <Globe size={10} />, type: 'text' },
];
/**
* BuilderScenePanel — Sliding panel for scene-specific configuration.
*
* This is the template-builder counterpart of TextPanel/ShapesPanel.
* It manages scene metadata (name, type, duration, background, layout)
* and lets users add brand variables and brand assets as TimelineElements.
*/
export const BuilderScenePanel: React.FC<BuilderScenePanelProps> = ({
onClose,
scene,
onUpdateScene,
brandContent,
designMD,
isVideo,
}) => {
const {
setTimelineElements,
setSelectedElementId,
layers,
activeLayerId,
durationInFrames,
} = useEditor();
// ── Add a brand-variable element to the canvas via EditorContext ──
const addBrandField = (
type: ExpressField['type'],
label: string,
brandSource?: ExpressField['brandSource'],
) => {
const newId = 'el-' + Date.now();
const elType: TimelineElement['type'] = type === 'text' ? 'text' : 'image';
// Determine target layer
let targetLayerId = activeLayerId;
const activeLayer = layers.find(l => l.id === activeLayerId);
if (!activeLayer || activeLayer.type === 'brand' || activeLayer.type === 'audio') {
const visual = layers.find(l => l.type === 'visual' || l.type == null);
if (visual) targetLayerId = visual.id;
}
const newElement: TimelineElement = {
id: newId,
layerId: targetLayerId,
type: elType,
content: brandSource ? `{${brandSource}}` : label,
startFrame: 0,
endFrame: durationInFrames,
x: 50,
y: 50,
width: type === 'text' ? 80 : 40,
height: type === 'text' ? 10 : 20,
fontSize: type === 'text' ? 24 : undefined,
fontWeight: type === 'text' ? 400 : undefined,
elementName: label,
notes: JSON.stringify({
__expressField: true,
brandSource,
required: false,
fieldType: type,
}),
};
setTimelineElements(prev => [...prev, newElement]);
setSelectedElementId(newId);
};
// ── Add a brand content asset ──
const addBrandAsset = (asset: BrandContentPiece) => {
const newId = 'el-asset-' + Date.now();
const elType: TimelineElement['type'] = asset.type === 'custom-image' ? 'image' : 'text';
let targetLayerId = activeLayerId;
const activeLayer = layers.find(l => l.id === activeLayerId);
if (!activeLayer || activeLayer.type === 'brand' || activeLayer.type === 'audio') {
const visual = layers.find(l => l.type === 'visual' || l.type == null);
if (visual) targetLayerId = visual.id;
}
const newElement: TimelineElement = {
id: newId,
layerId: targetLayerId,
type: elType,
content: asset.content.text || asset.name,
startFrame: 0,
endFrame: durationInFrames,
x: 50,
y: 50,
width: 40,
height: 20,
fontSize: asset.style.fontSize || 20,
fontWeight: 600,
elementName: asset.name,
notes: JSON.stringify({
__expressField: true,
brandAssetId: asset.id,
required: false,
fieldType: asset.type === 'custom-image' ? 'media' : 'text',
}),
};
setTimelineElements(prev => [...prev, newElement]);
setSelectedElementId(newId);
};
return (
<div className="w-64 bg-neutral-900 border-r border-neutral-800/60 flex flex-col h-full z-10 shrink-0 shadow-lg animate-in slide-in-from-left-2 duration-200">
<div className="p-3 border-b border-neutral-800 flex items-center justify-between">
<h3 className="text-sm font-semibold text-white flex items-center gap-2">
<Layers size={14} className="text-amber-400" />
Escena
</h3>
<button onClick={onClose} title="Cerrar Panel" className="text-neutral-400 hover:text-white p-1 rounded-md hover:bg-neutral-800 transition-colors">
<X size={16} />
</button>
</div>
<div className="p-3 flex-1 overflow-y-auto space-y-4 custom-scrollbar">
{/* Scene name */}
<div className="space-y-1">
<label className="text-[9px] text-neutral-500 uppercase tracking-wider font-semibold">Nombre de la escena</label>
<input
type="text"
value={scene.name}
onChange={(e) => onUpdateScene({ ...scene, name: e.target.value })}
className="w-full bg-neutral-800 border border-neutral-700 rounded-lg px-3 py-1.5 text-sm text-white focus:border-violet-500/50 focus:outline-none"
/>
</div>
{/* Type + Duration (video only) */}
{isVideo && (
<div className="flex gap-2">
<div className="flex-1 space-y-1">
<label className="text-[9px] text-neutral-500 uppercase tracking-wider font-semibold">Tipo</label>
<select
value={scene.type}
onChange={(e) => onUpdateScene({ ...scene, type: e.target.value as ExpressScene['type'] })}
className="w-full bg-neutral-800 border border-neutral-700 rounded-lg px-2 py-1.5 text-xs text-white focus:border-violet-500/50 focus:outline-none"
>
<option value="intro">Intro</option>
<option value="content">Contenido</option>
<option value="outro">Outro</option>
</select>
</div>
<div className="w-20 space-y-1">
<label className="text-[9px] text-neutral-500 uppercase tracking-wider font-semibold flex items-center gap-1">
<Clock size={8} /> Duración
</label>
<div className="flex items-center gap-1">
<input
type="number"
min={1}
max={30}
value={scene.durationSeconds}
onChange={(e) => onUpdateScene({ ...scene, durationSeconds: Math.max(1, parseInt(e.target.value) || 1) })}
className="w-full bg-neutral-800 border border-neutral-700 rounded-lg px-2 py-1.5 text-xs text-white text-center focus:border-violet-500/50 focus:outline-none"
/>
<span className="text-[9px] text-neutral-500">s</span>
</div>
</div>
</div>
)}
{/* Quick-add field buttons */}
<div className="space-y-2">
<label className="text-[9px] text-neutral-500 uppercase tracking-wider font-semibold">
Agregar campos
</label>
<div className="flex gap-1">
<button
onClick={() => addBrandField('text', 'Texto')}
title="Agregar campo de texto"
className="flex-1 flex items-center justify-center gap-1 py-1.5 rounded-lg border border-dashed border-neutral-700 text-[9px] text-neutral-500 hover:border-violet-500/50 hover:text-violet-400 transition-all"
>
<Plus size={8} /> Texto
</button>
<button
onClick={() => addBrandField('media', 'Media')}
title="Agregar campo de media"
className="flex-1 flex items-center justify-center gap-1 py-1.5 rounded-lg border border-dashed border-neutral-700 text-[9px] text-neutral-500 hover:border-sky-500/50 hover:text-sky-400 transition-all"
>
<Plus size={8} /> Media
</button>
</div>
</div>
<hr className="border-neutral-800/50" />
{/* Brand Variables */}
<div className="space-y-2">
<label className="text-[9px] text-neutral-500 uppercase tracking-wider font-semibold flex items-center gap-1">
<Zap size={8} className="text-violet-400" /> Variables de Marca
</label>
<div className="grid grid-cols-2 gap-1">
{BRAND_VARIABLES.map(v => (
<button
key={v.source}
onClick={() => addBrandField(v.type, v.label, v.source)}
title={`Insertar {${v.source}}`}
className="flex items-center gap-1.5 px-2 py-1.5 rounded-lg bg-violet-500/5 border border-violet-500/15 text-[9px] text-violet-300 hover:bg-violet-500/10 hover:border-violet-500/30 transition-all"
>
{v.icon} {v.label}
</button>
))}
</div>
</div>
<hr className="border-neutral-800/50" />
{/* ── Diseño y Fondo (collapsible) ── */}
<CollapsibleSection title="Diseño y Fondo">
{/* Layout */}
<div className="space-y-1.5">
<label className="text-[9px] text-neutral-500 uppercase tracking-wider font-semibold flex items-center gap-1">
<Layers size={8} /> Layout
</label>
<div className="grid grid-cols-2 gap-1">
{LAYOUTS.map(l => (
<button
key={l.value}
onClick={() => onUpdateScene({ ...scene, layout: l.value })}
title={l.label}
className={`flex items-center gap-1 px-2 py-1.5 rounded-lg text-[9px] font-medium transition-all border ${
scene.layout === l.value
? 'bg-violet-600/15 border-violet-500/40 text-violet-300'
: 'bg-neutral-800 border-neutral-700 text-neutral-500 hover:text-neutral-300 hover:border-neutral-600'
}`}
>
<span>{l.icon}</span> {l.label}
</button>
))}
</div>
</div>
{/* Background */}
<div className="space-y-1.5">
<label className="text-[9px] text-neutral-500 uppercase tracking-wider font-semibold">Fondo</label>
<div className="flex gap-1">
{(['brand', 'solid', 'gradient', 'media'] as const).map(bg => (
<button
key={bg}
onClick={() => onUpdateScene({ ...scene, background: { type: bg } })}
title={`Fondo: ${bg}`}
className={`flex-1 py-1.5 rounded-lg text-[9px] font-medium transition-all border ${
scene.background?.type === bg
? 'bg-violet-600/15 border-violet-500/40 text-violet-300'
: 'bg-neutral-800 border-neutral-700 text-neutral-500 hover:text-neutral-300'
}`}
>
{bg === 'brand' ? '🎨' : bg === 'solid' ? '⬛' : bg === 'gradient' ? '🌈' : '📷'}
</button>
))}
</div>
</div>
{/* Brand Content Assets */}
{brandContent.length > 0 && (
<div className="space-y-2">
<label className="text-[9px] text-neutral-500 uppercase tracking-wider font-semibold flex items-center gap-1">
<ImageIcon size={8} className="text-amber-400" /> Assets de Marca
</label>
<div className="grid grid-cols-2 gap-1">
{brandContent.map(asset => (
<button
key={asset.id}
onClick={() => addBrandAsset(asset)}
title={`Insertar asset: ${asset.name}`}
className="flex items-center gap-1.5 px-2 py-1.5 rounded-lg bg-amber-500/5 border border-amber-500/15 text-[9px] text-amber-300 hover:bg-amber-500/10 hover:border-amber-500/30 transition-all text-left truncate"
>
{asset.thumbnail ? (
<img src={asset.thumbnail} alt="" className="w-4 h-4 rounded object-cover shrink-0" />
) : (
<div className="w-4 h-4 rounded bg-amber-500/20 shrink-0" />
)}
<span className="truncate">{asset.name}</span>
</button>
))}
</div>
</div>
)}
</CollapsibleSection>
</div>
</div>
);
};
@@ -0,0 +1,475 @@
import React from 'react';
import {
Settings2, Tag, ToggleLeft, Type, Image as ImageIcon, Video, Pentagon,
Zap, AlertCircle, Hash, Eye, EyeOff, ArrowLeftRight,
} from 'lucide-react';
import { TemplateField, TemplateFieldNature, TemplateFieldType, BrandSource, StickerConfig } from '../../../types';
import { useTemplateBuilder } from '../../../context/TemplateBuilderContext';
import { FieldInspector } from '../../ui/FieldInspector';
import { CollapsibleSection } from '../../ui/CollapsibleSection';
import { DEFAULT_STICKER, getPlatformIcon } from './PlatformIcons';
/** Nature display config */
const NATURE_CONFIG: Record<TemplateFieldNature, { label: string; color: string; icon: React.ReactNode }> = {
'static': { label: 'Estático', color: '#6b7280', icon: <Pentagon size={10} /> },
'brand-variable': { label: 'Variable de marca', color: '#a78bfa', icon: <Zap size={10} /> },
'editable-slot': { label: 'Campo editable', color: '#38bdf8', icon: <Tag size={10} /> },
};
/** Type options */
const TYPE_OPTIONS: { value: TemplateFieldType; label: string; icon: React.ReactNode }[] = [
{ value: 'text', label: 'Texto', icon: <Type size={10} /> },
{ value: 'image', label: 'Imagen', icon: <ImageIcon size={10} /> },
{ value: 'video', label: 'Video', icon: <Video size={10} /> },
{ value: 'shape', label: 'Forma', icon: <Pentagon size={10} /> },
{ value: 'sticker', label: 'Sticker', icon: <Zap size={10} /> },
];
/** Brand sources */
const BRAND_SOURCES: { value: BrandSource; label: string }[] = [
{ value: 'brand-name', label: 'Nombre de Marca' },
{ value: 'tagline', label: 'Tagline' },
{ value: 'logo', label: 'Logo' },
{ value: 'instagram', label: 'Instagram' },
{ value: 'tiktok', label: 'TikTok' },
{ value: 'twitter', label: 'X / Twitter' },
{ value: 'youtube', label: 'YouTube' },
{ value: 'website', label: 'Website' },
];
/**
* FieldConfigPanel — Right panel in the Template Builder.
*
* Shows properties for the selected field, adapted by its nature.
* Reuses FieldInspector for position editing and CollapsibleSection for grouping.
*/
export const FieldConfigPanel: React.FC = () => {
const {
fields,
selectedFieldId,
setSelectedFieldId,
updateField,
resolvedDesignMD,
editableSlotCount,
totalFieldCount,
templateMeta,
} = useTemplateBuilder();
const field = fields.find(f => f.id === selectedFieldId);
// No selection — show hint
if (!field) {
return (
<div className="flex flex-col items-center justify-center h-full px-6 text-center py-8">
<p className="text-[11px] text-neutral-500 leading-relaxed">
Selecciona un campo en el canvas o en la lista para configurarlo.
</p>
</div>
);
}
const natureConfig = NATURE_CONFIG[field.nature];
const brandColors = [resolvedDesignMD.primaryColor, resolvedDesignMD.secondaryColor, resolvedDesignMD.textColor].filter(Boolean);
return (
<div>
{/* Header */}
<div className="p-3 border-b border-neutral-800">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Settings2 size={14} className="text-neutral-400" />
<span className="text-sm font-semibold text-white truncate max-w-[140px]">{field.label}</span>
</div>
<button
onClick={() => setSelectedFieldId(null)}
title="Deseleccionar"
className="text-neutral-400 hover:text-white p-1 rounded-md hover:bg-neutral-800 transition-colors text-xs"
>
</button>
</div>
<div className="flex items-center gap-1.5 mt-1">
<span
className="text-[8px] font-bold px-1.5 py-0.5 rounded"
style={{ color: natureConfig.color, backgroundColor: `${natureConfig.color}15`, border: `1px solid ${natureConfig.color}30` }}
>
{natureConfig.icon} {natureConfig.label}
</span>
</div>
</div>
<div className="flex-1 overflow-y-auto p-3 space-y-4 custom-scrollbar">
{/* ── Label ── */}
<div className="space-y-1">
<label className="text-[9px] text-neutral-500 uppercase tracking-wider font-semibold">Etiqueta</label>
<input
type="text"
value={field.label}
onChange={(e) => updateField(field.id, { label: e.target.value })}
placeholder="Nombre del campo"
className="w-full bg-neutral-800 border border-neutral-700 rounded-lg px-3 py-1.5 text-sm text-white placeholder-neutral-600 focus:border-violet-500/50 focus:outline-none"
/>
</div>
{/* ── Nature selector ── */}
<div className="space-y-1">
<label className="text-[9px] text-neutral-500 uppercase tracking-wider font-semibold">Naturaleza</label>
<div className="flex gap-1">
{(['static', 'brand-variable', 'editable-slot'] as TemplateFieldNature[]).map(nature => {
const cfg = NATURE_CONFIG[nature];
const isActive = field.nature === nature;
return (
<button
key={nature}
onClick={() => updateField(field.id, { nature })}
title={cfg.label}
className={`flex-1 flex items-center justify-center gap-1 py-1.5 rounded-lg text-[8px] font-medium transition-all border ${
isActive
? 'text-white'
: 'bg-neutral-800 border-neutral-700 text-neutral-500 hover:text-neutral-300'
}`}
style={isActive ? {
backgroundColor: `${cfg.color}15`,
borderColor: `${cfg.color}40`,
color: cfg.color,
} : {}}
>
{cfg.icon} {nature === 'editable-slot' ? 'Campo' : nature === 'brand-variable' ? 'Auto' : 'Fijo'}
</button>
);
})}
</div>
</div>
{/* ── Type selector ── */}
<div className="space-y-1">
<label className="text-[9px] text-neutral-500 uppercase tracking-wider font-semibold">Tipo</label>
<div className="flex gap-1">
{TYPE_OPTIONS.map(opt => (
<button
key={opt.value}
onClick={() => updateField(field.id, { type: opt.value })}
title={opt.label}
className={`flex-1 flex items-center justify-center gap-1 py-1.5 rounded-lg text-[9px] font-medium transition-all border ${
field.type === opt.value
? 'bg-violet-600/15 border-violet-500/40 text-violet-300'
: 'bg-neutral-800 border-neutral-700 text-neutral-500 hover:text-neutral-300'
}`}
>
{opt.icon} {opt.label}
</button>
))}
</div>
</div>
{/* ── Required toggle (editable-slot only) ── */}
{field.nature === 'editable-slot' && (
<div className="flex items-center justify-between">
<label className="text-[10px] text-neutral-400 flex items-center gap-1.5">
<AlertCircle size={10} />
Obligatorio
</label>
<button
onClick={() => updateField(field.id, { required: !field.required })}
title={field.required ? 'Marcar como opcional' : 'Marcar como obligatorio'}
className={`flex items-center gap-1 px-2 py-1 rounded text-[9px] font-medium transition-all ${
field.required
? 'bg-red-500/15 text-red-400 border border-red-500/30'
: 'bg-neutral-800 text-neutral-500 border border-neutral-700 hover:text-neutral-300'
}`}
>
<ToggleLeft size={10} />
{field.required ? 'Sí' : 'No'}
</button>
</div>
)}
{/* ── Brand source (brand-variable only) ── */}
{field.nature === 'brand-variable' && (
<div className="space-y-1">
<label className="text-[9px] text-neutral-500 uppercase tracking-wider font-semibold flex items-center gap-1">
<Zap size={8} className="text-violet-400" /> Fuente de datos
</label>
<select
value={field.brandSource || ''}
onChange={(e) => updateField(field.id, { brandSource: e.target.value as BrandSource })}
className="w-full bg-neutral-800 border border-neutral-700 rounded-lg px-2 py-1.5 text-xs text-white focus:border-violet-500/50 focus:outline-none"
>
<option value="">Seleccionar...</option>
{BRAND_SOURCES.map(s => (
<option key={s.value} value={s.value}>{s.label}</option>
))}
</select>
</div>
)}
{/* ── Sticker config (sticker type only) ── */}
{field.type === 'sticker' && (() => {
const sticker: StickerConfig = field.style.sticker || DEFAULT_STICKER;
const updateSticker = (patch: Partial<StickerConfig>) => {
updateField(field.id, {
style: { ...field.style, sticker: { ...sticker, ...patch } },
});
};
return (
<CollapsibleSection title="Sticker" badge={1} defaultOpen={true}>
<div className="space-y-3">
{/* Show icon */}
<div className="flex items-center justify-between">
<label className="text-[9px] text-neutral-400 flex items-center gap-1">
{sticker.showIcon ? <Eye size={10} /> : <EyeOff size={10} />}
Mostrar ícono
</label>
<button
onClick={() => updateSticker({ showIcon: !sticker.showIcon })}
title={sticker.showIcon ? 'Ocultar ícono' : 'Mostrar ícono'}
className={`flex items-center gap-1 px-2 py-1 rounded text-[9px] font-medium transition-all ${
sticker.showIcon
? 'bg-violet-500/15 text-violet-300 border border-violet-500/30'
: 'bg-neutral-800 text-neutral-500 border border-neutral-700'
}`}
>
{sticker.showIcon ? 'Sí' : 'No'}
</button>
</div>
{/* Icon position */}
{sticker.showIcon && (
<div className="flex items-center justify-between">
<label className="text-[9px] text-neutral-400 flex items-center gap-1">
<ArrowLeftRight size={10} />
Posición ícono
</label>
<div className="flex gap-1">
{(['left', 'right'] as const).map(pos => (
<button
key={pos}
onClick={() => updateSticker({ iconPosition: pos })}
title={pos === 'left' ? 'Ícono a la izquierda' : 'Ícono a la derecha'}
className={`px-2 py-1 rounded text-[8px] font-medium transition-all border ${
sticker.iconPosition === pos
? 'bg-violet-600/15 border-violet-500/40 text-violet-300'
: 'bg-neutral-800 border-neutral-700 text-neutral-500 hover:text-neutral-300'
}`}
>
{pos === 'left' ? '← Izq' : 'Der →'}
</button>
))}
</div>
</div>
)}
{/* @ prefix */}
<div className="flex items-center justify-between">
<label className="text-[9px] text-neutral-400">Prefijo @</label>
<button
onClick={() => updateSticker({ showAtPrefix: !sticker.showAtPrefix })}
title={sticker.showAtPrefix ? 'Ocultar @' : 'Mostrar @'}
className={`px-2 py-1 rounded text-[9px] font-medium transition-all ${
sticker.showAtPrefix
? 'bg-violet-500/15 text-violet-300 border border-violet-500/30'
: 'bg-neutral-800 text-neutral-500 border border-neutral-700'
}`}
>
{sticker.showAtPrefix ? '@usuario' : 'usuario'}
</button>
</div>
{/* Style: plain or pill */}
<div className="flex items-center justify-between">
<label className="text-[9px] text-neutral-400">Estilo</label>
<div className="flex gap-1">
{(['plain', 'pill'] as const).map(style => (
<button
key={style}
onClick={() => updateSticker({ stickerStyle: style })}
title={style === 'plain' ? 'Texto plano' : 'Pill con fondo'}
className={`px-2 py-1 rounded text-[8px] font-medium transition-all border ${
sticker.stickerStyle === style
? 'bg-violet-600/15 border-violet-500/40 text-violet-300'
: 'bg-neutral-800 border-neutral-700 text-neutral-500 hover:text-neutral-300'
}`}
>
{style === 'plain' ? 'Plano' : 'Pill'}
</button>
))}
</div>
</div>
{/* Gap */}
<div className="space-y-1">
<div className="flex items-center justify-between">
<label className="text-[9px] text-neutral-400">Gap (px)</label>
<span className="text-[9px] text-neutral-500 font-mono">{sticker.gap}px</span>
</div>
<input
type="range"
min={0}
max={16}
value={sticker.gap}
onChange={(e) => updateSticker({ gap: parseInt(e.target.value) })}
className="w-full accent-violet-500 h-1"
/>
</div>
{/* Icon color */}
{sticker.showIcon && (
<div className="space-y-1">
<label className="text-[9px] text-neutral-400">Color ícono</label>
<div className="flex items-center gap-2">
<input
type="color"
value={sticker.iconColor || resolvedDesignMD.primaryColor}
onChange={(e) => updateSticker({ iconColor: e.target.value })}
className="w-6 h-6 rounded border border-neutral-700 cursor-pointer bg-transparent"
/>
<span className="text-[9px] text-neutral-500 font-mono">
{sticker.iconColor || resolvedDesignMD.primaryColor}
</span>
{sticker.iconColor && (
<button
onClick={() => updateSticker({ iconColor: undefined })}
title="Usar color de marca"
className="text-[8px] text-neutral-500 hover:text-neutral-300 transition-colors"
>
Reset
</button>
)}
</div>
{/* Preview */}
<div className="flex items-center gap-2 mt-1 px-2 py-1.5 bg-neutral-800/60 rounded-lg border border-neutral-700/50">
<span style={{ color: sticker.iconColor || resolvedDesignMD.primaryColor }}>
{getPlatformIcon(field.brandSource, 14)}
</span>
<span className="text-[10px] text-neutral-300">Vista previa</span>
</div>
</div>
)}
</div>
</CollapsibleSection>
);
})()}
<hr className="border-neutral-800/50" />
{/* ── Position (FieldInspector) ── */}
<FieldInspector
position={field.position}
onPositionChange={(pos) => {
updateField(field.id, {
position: { ...field.position, ...pos },
});
}}
textStyle={field.type === 'text' ? {
fontSize: field.style.fontSize,
fontWeight: field.style.fontWeight,
fontFamily: field.style.fontFamily,
color: field.style.color,
textAlign: field.style.textAlign,
opacity: field.style.opacity,
useBrandStyle: field.style.useBrandStyle,
textRole: field.style.textRole,
} : undefined}
onTextStyleChange={field.type === 'text' ? (style) => {
updateField(field.id, {
style: { ...field.style, ...style },
});
} : undefined}
fieldType={field.type === 'video' ? 'media' : field.type === 'shape' ? 'text' : field.type}
fieldLabel={field.label}
brandFont={resolvedDesignMD.baseFont?.split(',')[0]?.replace(/"/g, '')}
brandColors={brandColors}
resolvedDesignMD={resolvedDesignMD}
/>
{/* ── Rules (editable-slot only) ── */}
{field.nature === 'editable-slot' && (
<CollapsibleSection title="Reglas de validación" defaultOpen={false}>
<div className="space-y-2">
{/* Text rules */}
{field.type === 'text' && (
<>
<div className="space-y-1">
<label className="text-[9px] text-neutral-500">Máx. caracteres</label>
<input
type="number"
min={0}
value={field.rules?.maxChars || ''}
onChange={(e) => updateField(field.id, {
rules: { ...field.rules, maxChars: parseInt(e.target.value) || undefined },
})}
placeholder="Sin límite"
className="w-full bg-neutral-800 border border-neutral-700 rounded-lg px-2 py-1 text-xs text-white focus:border-violet-500/50 focus:outline-none"
/>
</div>
<div className="flex items-center justify-between">
<label className="text-[9px] text-neutral-400">Multilínea</label>
<button
onClick={() => updateField(field.id, {
rules: { ...field.rules, multiline: !field.rules?.multiline },
})}
title="Alternar multilínea"
className={`text-[9px] px-2 py-1 rounded transition-all ${
field.rules?.multiline
? 'bg-violet-500/15 text-violet-300 border border-violet-500/30'
: 'bg-neutral-800 text-neutral-500 border border-neutral-700'
}`}
>
{field.rules?.multiline ? 'Sí' : 'No'}
</button>
</div>
</>
)}
{/* Image/Video rules */}
{(field.type === 'image' || field.type === 'video') && (
<>
<div className="space-y-1">
<label className="text-[9px] text-neutral-500">Aspect ratio</label>
<input
type="text"
value={field.rules?.aspectRatio || ''}
onChange={(e) => updateField(field.id, {
rules: { ...field.rules, aspectRatio: e.target.value || undefined },
})}
placeholder="ej. 16:9"
className="w-full bg-neutral-800 border border-neutral-700 rounded-lg px-2 py-1 text-xs text-white focus:border-violet-500/50 focus:outline-none"
/>
</div>
<div className="flex gap-2">
<div className="flex-1 space-y-1">
<label className="text-[9px] text-neutral-500">Ancho mín.</label>
<input
type="number"
min={0}
value={field.rules?.minWidth || ''}
onChange={(e) => updateField(field.id, {
rules: { ...field.rules, minWidth: parseInt(e.target.value) || undefined },
})}
placeholder="px"
className="w-full bg-neutral-800 border border-neutral-700 rounded-lg px-2 py-1 text-xs text-white focus:border-violet-500/50 focus:outline-none"
/>
</div>
<div className="flex-1 space-y-1">
<label className="text-[9px] text-neutral-500">Alto mín.</label>
<input
type="number"
min={0}
value={field.rules?.minHeight || ''}
onChange={(e) => updateField(field.id, {
rules: { ...field.rules, minHeight: parseInt(e.target.value) || undefined },
})}
placeholder="px"
className="w-full bg-neutral-800 border border-neutral-700 rounded-lg px-2 py-1 text-xs text-white focus:border-violet-500/50 focus:outline-none"
/>
</div>
</div>
</>
)}
</div>
</CollapsibleSection>
)}
</div>
</div>
);
};
@@ -0,0 +1,435 @@
import React, { useCallback, useState } from 'react';
import {
Plus, Type, Image as ImageIcon, Video, Pentagon, Zap,
Trash2, GripVertical, Sparkles, Globe, Instagram, AtSign, Star, Layers,
Eye, EyeOff, Lock, Unlock,
} from 'lucide-react';
import { TemplateField, TemplateFieldNature, BrandSource, StickerConfig } from '../../../types';
import { useTemplateBuilder } from '../../../context/TemplateBuilderContext';
import { DEFAULT_STICKER } from './PlatformIcons';
/** Brand variables available for insertion */
const BRAND_VARIABLES: { source: BrandSource; label: string; icon: React.ReactNode; fieldType: 'text' | 'image' | 'sticker' }[] = [
{ source: 'brand-name', label: 'Nombre', icon: <Type size={10} />, fieldType: 'text' },
{ source: 'tagline', label: 'Tagline', icon: <Sparkles size={10} />, fieldType: 'text' },
{ source: 'logo', label: 'Logo', icon: <Star size={10} />, fieldType: 'image' },
{ source: 'instagram', label: 'Instagram', icon: <Instagram size={10} />, fieldType: 'sticker' },
{ source: 'tiktok', label: 'TikTok', icon: <AtSign size={10} />, fieldType: 'sticker' },
{ source: 'twitter', label: 'X / Twitter', icon: <AtSign size={10} />, fieldType: 'sticker' },
{ source: 'youtube', label: 'YouTube', icon: <AtSign size={10} />, fieldType: 'sticker' },
{ source: 'website', label: 'Website', icon: <Globe size={10} />, fieldType: 'sticker' },
];
/** Type icon mapping */
function getTypeIcon(type: TemplateField['type'], size = 10): React.ReactNode {
switch (type) {
case 'text': return <Type size={size} />;
case 'image': return <ImageIcon size={size} />;
case 'video': return <Video size={size} />;
case 'shape': return <Pentagon size={size} />;
case 'sticker': return <Zap size={size} />;
}
}
/** Nature badge config */
const NATURE_BADGE: Record<TemplateFieldNature, { label: string; color: string; bg: string; border: string }> = {
'static': { label: 'fijo', color: '#9ca3af', bg: 'rgba(107,114,128,0.10)', border: 'rgba(107,114,128,0.25)' },
'brand-variable': { label: 'auto', color: '#c4b5fd', bg: 'rgba(167,139,250,0.10)', border: 'rgba(167,139,250,0.25)' },
'editable-slot': { label: 'campo', color: '#7dd3fc', bg: 'rgba(56,189,248,0.10)', border: 'rgba(56,189,248,0.25)' },
};
/**
* FieldSchemaPanel — Layers panel (Photoshop/Figma style) for the Template Builder.
*
* Photoshop convention: first row = front (highest z-index), last row = back.
* Internally, the fields array is ordered bottom-to-top (index 0 = back, last = front).
* The panel renders the list REVERSED so the topmost layer appears first.
*
* Each layer row shows: visibility toggle, lock toggle, type icon, editable name,
* nature badge, and optional req badge.
*
* Bottom section: quick-add buttons for new fields and brand variables.
*/
export const FieldSchemaPanel: React.FC<{ onClose?: () => void }> = ({ onClose }) => {
const {
fields,
addField,
removeField,
reorderField,
moveField,
updateField,
selectedFieldId,
setSelectedFieldId,
editableSlotCount,
totalFieldCount,
} = useTemplateBuilder();
// Reversed for Photoshop convention: front layers on top
const layersReversed = [...fields].reverse();
// ── Drag & Drop state ──
const [dragId, setDragId] = useState<string | null>(null);
const [dropDisplayIdx, setDropDisplayIdx] = useState<number | null>(null);
const handleDragStart = useCallback((fieldId: string) => {
setDragId(fieldId);
}, []);
const handleDragOver = useCallback((displayIdx: number) => {
setDropDisplayIdx(displayIdx);
}, []);
const handleDrop = useCallback(() => {
if (dragId && dropDisplayIdx !== null) {
// Convert display index (reversed) → array index
// Display 0 = array last, Display N = array first
const totalLen = fields.length;
const targetArrayIdx = totalLen - 1 - dropDisplayIdx;
moveField(dragId, Math.max(0, Math.min(targetArrayIdx, totalLen - 1)));
}
setDragId(null);
setDropDisplayIdx(null);
}, [dragId, dropDisplayIdx, fields.length, moveField]);
const handleDragEnd = useCallback(() => {
setDragId(null);
setDropDisplayIdx(null);
}, []);
// ── Add handlers ──
const handleAddEditableSlot = useCallback((type: TemplateField['type'], label: string) => {
const newId = addField({
nature: 'editable-slot',
type,
label,
required: false,
});
setSelectedFieldId(newId);
}, [addField, setSelectedFieldId]);
const handleAddBrandVariable = useCallback((source: BrandSource, label: string, type: 'text' | 'image' | 'sticker') => {
const stickerDefaults: Partial<StickerConfig> | undefined = type === 'sticker'
? { ...DEFAULT_STICKER, showAtPrefix: source !== 'website' }
: undefined;
const newId = addField({
nature: 'brand-variable',
type,
label,
brandSource: source,
content: `{${source}}`,
...(stickerDefaults ? { style: { sticker: stickerDefaults as StickerConfig } } : {}),
});
setSelectedFieldId(newId);
}, [addField, setSelectedFieldId]);
const handleAddStatic = useCallback((type: TemplateField['type'], label: string) => {
const newId = addField({
nature: 'static',
type,
label,
content: label,
});
setSelectedFieldId(newId);
}, [addField, setSelectedFieldId]);
return (
<div className="w-64 bg-neutral-900 border-r border-neutral-800/60 flex flex-col h-full z-10 shrink-0 shadow-lg">
{/* ── Header ── */}
<div className="p-3 border-b border-neutral-800 flex items-center justify-between shrink-0">
<div>
<h3 className="text-sm font-semibold text-white flex items-center gap-2">
<Layers size={14} className="text-violet-400" />
Capas
</h3>
<p className="text-[9px] text-neutral-500 mt-0.5 font-mono">
{totalFieldCount} capa{totalFieldCount !== 1 ? 's' : ''} · {editableSlotCount} campo{editableSlotCount !== 1 ? 's' : ''}
</p>
</div>
{onClose && (
<button onClick={onClose} title="Cerrar panel" className="text-neutral-400 hover:text-white p-1 rounded-md hover:bg-neutral-800 transition-colors text-xs">
</button>
)}
</div>
{/* ── Layers list (full height, scrollable) ── */}
<div className="flex-1 overflow-y-auto custom-scrollbar">
<div className="p-2 space-y-0">
{layersReversed.length === 0 ? (
<p className="text-[9px] text-neutral-600 text-center py-6 italic">
Sin capas. Agrega elementos abajo.
</p>
) : (
layersReversed.map((field, displayIdx) => (
<React.Fragment key={field.id}>
{/* Drop indicator line */}
{dropDisplayIdx === displayIdx && dragId !== field.id && (
<div className="h-0.5 bg-violet-500 rounded-full mx-1 my-0.5 shadow-sm shadow-violet-500/50" />
)}
<LayerRow
field={field}
isSelected={selectedFieldId === field.id}
isDragging={dragId === field.id}
onSelect={() => setSelectedFieldId(field.id)}
onRemove={() => removeField(field.id)}
onToggleVisible={() => updateField(field.id, { visible: field.visible === false ? true : false })}
onToggleLocked={() => updateField(field.id, { locked: !field.locked })}
onRename={(name) => updateField(field.id, { label: name })}
onDragStart={() => handleDragStart(field.id)}
onDragOver={() => handleDragOver(displayIdx)}
onDrop={handleDrop}
onDragEnd={handleDragEnd}
/>
</React.Fragment>
))
)}
{/* Drop indicator at very bottom */}
{dropDisplayIdx === layersReversed.length && (
<div className="h-0.5 bg-violet-500 rounded-full mx-1 my-0.5 shadow-sm shadow-violet-500/50" />
)}
</div>
<hr className="border-neutral-800/50 mx-3" />
{/* ═══ Add Fields ═══ */}
<div className="p-3 space-y-3">
{/* Editable slots */}
<div className="space-y-1.5">
<span className="text-[9px] text-neutral-500 uppercase tracking-wider font-semibold">
Agregar campo editable
</span>
<div className="grid grid-cols-2 gap-1">
<button
onClick={() => handleAddEditableSlot('text', 'Texto')}
title="Agregar campo de texto editable"
className="flex items-center justify-center gap-1 py-1.5 rounded-lg border border-dashed border-sky-500/30 text-[9px] text-sky-400/70 hover:border-sky-500/60 hover:text-sky-300 hover:bg-sky-500/5 transition-all"
>
<Plus size={8} /> Texto
</button>
<button
onClick={() => handleAddEditableSlot('image', 'Imagen')}
title="Agregar campo de imagen editable"
className="flex items-center justify-center gap-1 py-1.5 rounded-lg border border-dashed border-sky-500/30 text-[9px] text-sky-400/70 hover:border-sky-500/60 hover:text-sky-300 hover:bg-sky-500/5 transition-all"
>
<Plus size={8} /> Imagen
</button>
<button
onClick={() => handleAddEditableSlot('video', 'Video')}
title="Agregar campo de video editable"
className="flex items-center justify-center gap-1 py-1.5 rounded-lg border border-dashed border-sky-500/30 text-[9px] text-sky-400/70 hover:border-sky-500/60 hover:text-sky-300 hover:bg-sky-500/5 transition-all"
>
<Plus size={8} /> Video
</button>
<button
onClick={() => handleAddStatic('shape', 'Forma')}
title="Agregar elemento estático (forma)"
className="flex items-center justify-center gap-1 py-1.5 rounded-lg border border-dashed border-neutral-700 text-[9px] text-neutral-500 hover:border-neutral-600 hover:text-neutral-400 hover:bg-neutral-800/50 transition-all"
>
<Plus size={8} /> Forma
</button>
</div>
</div>
{/* Brand variables */}
<div className="space-y-1.5">
<span className="text-[9px] text-neutral-500 uppercase tracking-wider font-semibold flex items-center gap-1">
<Zap size={8} className="text-violet-400" /> Variables de marca
</span>
<div className="grid grid-cols-2 gap-1">
{BRAND_VARIABLES.map(v => (
<button
key={v.source}
onClick={() => handleAddBrandVariable(v.source, v.label, v.fieldType)}
title={`Insertar variable {${v.source}}`}
className="flex items-center gap-1.5 px-2 py-1.5 rounded-lg bg-violet-500/5 border border-violet-500/15 text-[9px] text-violet-300 hover:bg-violet-500/10 hover:border-violet-500/30 transition-all"
>
{v.icon} {v.label}
</button>
))}
</div>
</div>
</div>
</div>
</div>
);
};
// ═══════════════════════════════════════════════════════════════
// LayerRow — Individual layer in Photoshop-style list with DnD
// ═══════════════════════════════════════════════════════════════
interface LayerRowProps {
field: TemplateField;
isSelected: boolean;
isDragging: boolean;
onSelect: () => void;
onRemove: () => void;
onToggleVisible: () => void;
onToggleLocked: () => void;
onRename: (name: string) => void;
onDragStart: () => void;
onDragOver: () => void;
onDrop: () => void;
onDragEnd: () => void;
}
const LayerRow: React.FC<LayerRowProps> = ({
field,
isSelected,
isDragging,
onSelect,
onRemove,
onToggleVisible,
onToggleLocked,
onRename,
onDragStart,
onDragOver,
onDrop,
onDragEnd,
}) => {
const [isRenaming, setIsRenaming] = useState(false);
const [renameValue, setRenameValue] = useState(field.label);
const isVisible = field.visible !== false;
const isLocked = field.locked === true;
const isBg = field.isBackground === true;
const canRename = !isBg && field.nature !== 'brand-variable';
const canDrag = !isBg;
const badge = NATURE_BADGE[field.nature];
const handleDoubleClick = useCallback(() => {
if (!canRename) return;
setRenameValue(field.label);
setIsRenaming(true);
}, [field.label, canRename]);
const commitRename = useCallback(() => {
const trimmed = renameValue.trim();
if (trimmed && trimmed !== field.label) {
onRename(trimmed);
}
setIsRenaming(false);
}, [renameValue, field.label, onRename]);
const handleRenameKeyDown = useCallback((e: React.KeyboardEvent) => {
if (e.key === 'Enter') commitRename();
if (e.key === 'Escape') { setRenameValue(field.label); setIsRenaming(false); }
}, [commitRename, field.label]);
return (
<div
onClick={onSelect}
onDragOver={(e) => { e.preventDefault(); onDragOver(); }}
onDrop={(e) => { e.preventDefault(); onDrop(); }}
className={`flex items-center gap-1 rounded-md px-1.5 py-1 cursor-pointer transition-all group ${
isSelected
? 'bg-violet-500/10 border border-violet-500/40 ring-1 ring-violet-500/20'
: 'bg-transparent border border-transparent hover:bg-neutral-800/60 hover:border-neutral-700/50'
} ${!isVisible ? 'opacity-40' : ''} ${isDragging ? 'opacity-30' : ''}`}
>
{/* Drag handle */}
{canDrag ? (
<div
draggable
onDragStart={(e) => {
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/plain', field.id);
onDragStart();
}}
onDragEnd={onDragEnd}
className="cursor-grab active:cursor-grabbing text-neutral-600 hover:text-neutral-400 shrink-0 p-0.5"
title="Arrastrar para reordenar"
>
<GripVertical size={10} />
</div>
) : (
<div className="text-neutral-800 shrink-0 p-0.5" title="Capa de fondo (fija)">
<GripVertical size={10} />
</div>
)}
{/* Visibility toggle */}
<button
onClick={(e) => { e.stopPropagation(); onToggleVisible(); }}
title={isVisible ? 'Ocultar capa' : 'Mostrar capa'}
className="text-neutral-500 hover:text-white transition-colors p-0.5 shrink-0"
>
{isVisible ? <Eye size={10} /> : <EyeOff size={10} />}
</button>
{/* Lock toggle */}
<button
onClick={(e) => { e.stopPropagation(); onToggleLocked(); }}
title={isLocked ? 'Desbloquear capa' : 'Bloquear capa'}
className={`transition-colors p-0.5 shrink-0 ${
isLocked ? 'text-amber-400 hover:text-amber-300' : 'text-neutral-600 hover:text-neutral-400'
}`}
>
{isLocked ? <Lock size={10} /> : <Unlock size={10} />}
</button>
{/* Type icon */}
<span style={{ color: badge.color }} className="shrink-0">
{getTypeIcon(field.type)}
</span>
{/* Name (inline editable on double click) */}
{isRenaming && canRename ? (
<input
type="text"
value={renameValue}
onChange={(e) => setRenameValue(e.target.value)}
onBlur={commitRename}
onKeyDown={handleRenameKeyDown}
autoFocus
className="flex-1 bg-neutral-800 border border-violet-500/50 rounded px-1 py-0.5 text-[10px] text-white focus:outline-none focus:ring-1 focus:ring-violet-500/40 min-w-0"
onClick={(e) => e.stopPropagation()}
/>
) : (
<span
className="flex-1 text-[10px] text-neutral-300 truncate select-none"
onDoubleClick={canRename ? (e) => { e.stopPropagation(); handleDoubleClick(); } : undefined}
title={canRename ? 'Doble clic para renombrar' : 'Nombre heredado de la marca'}
>
{field.label}
</span>
)}
{/* Badge: FONDO for background, or nature badge */}
{isBg ? (
<span
className="text-[7px] px-1 py-0.5 rounded shrink-0 font-bold uppercase tracking-wider"
style={{ color: '#fbbf24', backgroundColor: 'rgba(251,191,36,0.10)', border: '1px solid rgba(251,191,36,0.30)' }}
>
fondo
</span>
) : (
<>
<span
className="text-[7px] px-1 py-0.5 rounded shrink-0 font-bold uppercase tracking-wider"
style={{ color: badge.color, backgroundColor: badge.bg, border: `1px solid ${badge.border}` }}
>
{badge.label}
</span>
{field.nature === 'editable-slot' && field.required && (
<span className="text-[7px] text-red-400 bg-red-500/10 px-1 py-0.5 rounded shrink-0 font-bold border border-red-500/20">
req
</span>
)}
</>
)}
{/* Delete (hover only, not for background) */}
{!isBg && (
<button
onClick={(e) => { e.stopPropagation(); onRemove(); }}
title="Eliminar capa"
className="text-neutral-600 hover:text-red-400 transition-colors p-px opacity-0 group-hover:opacity-100 shrink-0"
>
<Trash2 size={9} />
</button>
)}
</div>
);
};
@@ -0,0 +1,196 @@
import React from 'react';
import {
Zap, FileText, Hash, Film,
} from 'lucide-react';
import { TemplateField, DesignMD, CompanyProfile, ExpressScene } from '../../../types';
import { useTemplateBuilder } from '../../../context/TemplateBuilderContext';
import { TemplateFieldInput } from '../../shared/TemplateFieldInput';
/** Resolve brand variable preview text */
function resolveBrandPreview(field: TemplateField, designMD: DesignMD, company: CompanyProfile): string {
if (!field.brandSource) return '';
switch (field.brandSource) {
case 'brand-name': return company.name || designMD.brandName || 'Tu Marca';
case 'tagline': return company.tagline || '';
case 'logo': return '(Logo de marca)';
case 'instagram': return company.socialLinks?.instagram || '';
case 'tiktok': return company.socialLinks?.tiktok || '';
case 'twitter': return company.socialLinks?.x || '';
case 'youtube': return company.socialLinks?.youtube || '';
case 'website': return company.socialLinks?.website || '';
default: return '';
}
}
/**
* FormPreviewPanel — Preview of the auto-generated form that the end-user will see in Express.
*
* Shows only editable-slot fields in their formOrder, rendered as the appropriate input type.
* Brand variables appear as read-only info rows (not editable).
* This is the "Vista de formulario" toggle in the builder.
*
* Uses the shared TemplateFieldInput component in disabled mode.
*/
export const FormPreviewPanel: React.FC = () => {
const {
fields,
designMD,
company,
templateMeta,
editableSlotCount,
scenes,
} = useTemplateBuilder();
// Detect segments
const formSegments = scenes.filter(
(s: ExpressScene) => (s.type === 'intro' || s.type === 'outro') && s.segmentSource === 'form'
);
const brandSegments = scenes.filter(
(s: ExpressScene) => (s.type === 'intro' || s.type === 'outro') && s.segmentSource === 'brand'
);
const editableSlots = fields
.filter(f => f.nature === 'editable-slot')
.sort((a, b) => a.formOrder - b.formOrder);
const brandVars = fields.filter(f => f.nature === 'brand-variable');
return (
<div className="flex-1 flex items-start justify-center bg-neutral-950 p-6 overflow-auto min-h-0">
{/* Form card */}
<div className="w-full max-w-md bg-neutral-900 border border-neutral-800 rounded-2xl shadow-2xl shadow-black/50 overflow-hidden">
{/* Header */}
<div className="px-6 py-5 border-b border-neutral-800 bg-gradient-to-r from-sky-500/5 to-violet-500/5">
<div className="flex items-center gap-2 mb-2">
<FileText size={16} className="text-sky-400" />
<h2 className="text-sm font-bold text-white">Vista previa del formulario</h2>
</div>
<p className="text-[10px] text-neutral-500">
Este es el formulario que verá quien produzca contenido con esta plantilla.
</p>
<div className="mt-2 flex items-center gap-2">
<span className="text-[9px] text-sky-400 bg-sky-500/10 px-2 py-0.5 rounded-full font-medium">
<Hash size={8} className="inline mr-0.5" />
{editableSlotCount} campo{editableSlotCount !== 1 ? 's' : ''}
</span>
<span className="text-[9px] text-neutral-500">{templateMeta.name || 'Plantilla'}</span>
</div>
</div>
{/* Form fields */}
<div className="p-6 space-y-5">
{/* ── Form-sourced segment fields (video upload previews) ── */}
{formSegments.length > 0 && (
<div className="space-y-3 pb-4 border-b border-neutral-800/50 mb-4">
<div className="flex items-center gap-2">
<Film size={12} className="text-emerald-400" />
<span className="text-[10px] font-bold text-white uppercase tracking-wider">Segmentos de video</span>
</div>
{formSegments.map((scene: ExpressScene) => {
const isIntro = scene.type === 'intro';
const syntheticField: TemplateField = {
id: `segment-${scene.id}`,
nature: 'editable-slot',
type: 'video',
label: scene.segmentFieldLabel || (isIntro ? 'Video de intro' : 'Video de cierre'),
required: scene.segmentFieldRequired ?? true,
content: isIntro ? 'Video de intro' : 'Video de cierre',
position: { x: 50, y: 50, w: 100, h: 100 },
style: { opacity: 100 },
formOrder: isIntro ? -2 : 999,
};
return (
<TemplateFieldInput
key={syntheticField.id}
field={syntheticField}
value=""
onChange={() => {}}
designMD={designMD}
disabled
/>
);
})}
</div>
)}
{editableSlots.length === 0 && formSegments.length === 0 ? (
<div className="text-center py-8">
<Hash size={24} className="text-neutral-700 mx-auto mb-2" />
<p className="text-xs text-neutral-500">No hay campos editables.</p>
<p className="text-[10px] text-neutral-600 mt-1">
Agrega campos desde el panel "Campos" para que aparezcan aquí.
</p>
</div>
) : editableSlots.length === 0 ? null : (
editableSlots.map((field) => (
<TemplateFieldInput
key={field.id}
field={field}
value=""
onChange={() => {}}
designMD={designMD}
disabled
/>
))
)}
{/* Brand-sourced segments (read-only info) */}
{brandSegments.length > 0 && (
<div className="pt-4 border-t border-neutral-800/50">
<p className="text-[9px] text-emerald-400 uppercase tracking-wider font-semibold mb-3 flex items-center gap-1">
<Zap size={8} /> Segmentos automáticos
</p>
<div className="space-y-2">
{brandSegments.map((scene: ExpressScene) => (
<div
key={scene.id}
className="flex items-center gap-3 px-3 py-2.5 bg-emerald-500/5 border border-emerald-500/15 rounded-lg"
>
<Film size={10} className="text-emerald-400 shrink-0" />
<div className="flex-1 min-w-0">
<span className="text-[10px] text-emerald-300 font-medium">{scene.name}</span>
<span className="text-[9px] text-emerald-400/50 block">
{scene.durationSeconds}s desde la marca
</span>
</div>
<span className="text-[7px] text-emerald-400 bg-emerald-500/10 px-1.5 py-0.5 rounded font-bold shrink-0">
auto
</span>
</div>
))}
</div>
</div>
)}
{/* Brand variables (read-only info) */}
{brandVars.length > 0 && (
<div className="pt-4 border-t border-neutral-800/50">
<p className="text-[9px] text-violet-400 uppercase tracking-wider font-semibold mb-3 flex items-center gap-1">
<Zap size={8} /> Auto-completados desde la marca
</p>
<div className="space-y-2">
{brandVars.map(field => (
<div
key={field.id}
className="flex items-center gap-3 px-3 py-2.5 bg-violet-500/5 border border-violet-500/15 rounded-lg"
>
<Zap size={10} className="text-violet-400 shrink-0" />
<div className="flex-1 min-w-0">
<span className="text-[10px] text-violet-300 font-medium">{field.label}</span>
<span className="text-[9px] text-violet-400/50 block truncate">
{resolveBrandPreview(field, designMD, company)}
</span>
</div>
<span className="text-[7px] text-violet-400 bg-violet-500/10 px-1.5 py-0.5 rounded font-bold shrink-0">
auto
</span>
</div>
))}
</div>
</div>
)}
</div>
</div>
</div>
);
};
@@ -0,0 +1,68 @@
import React from 'react';
import { Globe } from 'lucide-react';
import { BrandSource } from '../../../types';
/**
* PlatformIcons — Inline SVG icons for social platforms.
* Used by BrandStickerContent to render the icon portion of a sticker.
*/
/** Instagram icon */
const InstagramIcon: React.FC<{ size: number }> = ({ size }) => (
<svg width={size} height={size} viewBox="0 0 24 24" fill="currentColor">
<path d="M12 2.163c3.204 0 3.584.012 4.85.07 3.252.148 4.771 1.691 4.919 4.919.058 1.265.069 1.645.069 4.849 0 3.205-.012 3.584-.069 4.849-.149 3.225-1.664 4.771-4.919 4.919-1.266.058-1.644.07-4.85.07-3.204 0-3.584-.012-4.849-.07-3.26-.149-4.771-1.699-4.919-4.92-.058-1.265-.07-1.644-.07-4.849 0-3.204.013-3.583.07-4.849.149-3.227 1.664-4.771 4.919-4.919 1.266-.057 1.645-.069 4.849-.069zM12 0C8.741 0 8.333.014 7.053.072 2.695.272.273 2.69.073 7.052.014 8.333 0 8.741 0 12c0 3.259.014 3.668.072 4.948.2 4.358 2.618 6.78 6.98 6.98C8.333 23.986 8.741 24 12 24c3.259 0 3.668-.014 4.948-.072 4.354-.2 6.782-2.618 6.979-6.98.059-1.28.073-1.689.073-4.948 0-3.259-.014-3.667-.072-4.947-.196-4.354-2.617-6.78-6.979-6.98C15.668.014 15.259 0 12 0zm0 5.838a6.162 6.162 0 100 12.324 6.162 6.162 0 000-12.324zM12 16a4 4 0 110-8 4 4 0 010 8zm6.406-11.845a1.44 1.44 0 100 2.881 1.44 1.44 0 000-2.881z" />
</svg>
);
/** TikTok icon */
const TikTokIcon: React.FC<{ size: number }> = ({ size }) => (
<svg width={size} height={size} viewBox="0 0 24 24" fill="currentColor">
<path d="M19.59 6.69a4.83 4.83 0 01-3.77-4.25V2h-3.45v13.67a2.89 2.89 0 01-2.88 2.5 2.89 2.89 0 01-2.89-2.89 2.89 2.89 0 012.89-2.89c.28 0 .54.04.79.1V9.01a6.27 6.27 0 00-.79-.05 6.34 6.34 0 00-6.34 6.34 6.34 6.34 0 006.34 6.34 6.34 6.34 0 006.33-6.34V9.19a8.16 8.16 0 004.77 1.53V7.27a4.84 4.84 0 01-1-.58z" />
</svg>
);
/** X (Twitter) icon */
const XIcon: React.FC<{ size: number }> = ({ size }) => (
<svg width={size} height={size} viewBox="0 0 24 24" fill="currentColor">
<path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z" />
</svg>
);
/** YouTube icon */
const YouTubeIcon: React.FC<{ size: number }> = ({ size }) => (
<svg width={size} height={size} viewBox="0 0 24 24" fill="currentColor">
<path d="M23.498 6.186a3.016 3.016 0 00-2.122-2.136C19.505 3.545 12 3.545 12 3.545s-7.505 0-9.377.505A3.017 3.017 0 00.502 6.186C0 8.07 0 12 0 12s0 3.93.502 5.814a3.016 3.016 0 002.122 2.136c1.871.505 9.376.505 9.376.505s7.505 0 9.377-.505a3.015 3.015 0 002.122-2.136C24 15.93 24 12 24 12s0-3.93-.502-5.814zM9.545 15.568V8.432L15.818 12l-6.273 3.568z" />
</svg>
);
/** Website / Globe icon — reuses Lucide Globe */
const WebsiteIcon: React.FC<{ size: number }> = ({ size }) => (
<Globe size={size} />
);
/** Get the platform icon component for a given brand source */
export function getPlatformIcon(source: BrandSource | string | undefined, size: number): React.ReactNode {
switch (source) {
case 'instagram': return <InstagramIcon size={size} />;
case 'tiktok': return <TikTokIcon size={size} />;
case 'twitter': return <XIcon size={size} />;
case 'youtube': return <YouTubeIcon size={size} />;
case 'website': return <WebsiteIcon size={size} />;
default: return null;
}
}
/** Brand sources that produce stickers (icon + text) */
export const SOCIAL_SOURCES: string[] = ['instagram', 'tiktok', 'twitter', 'youtube', 'website'];
/** Check if a brand source should produce a sticker */
export const isSocialSource = (source?: string): boolean => SOCIAL_SOURCES.includes(source || '');
/** Default sticker configuration */
export const DEFAULT_STICKER = {
showIcon: true,
iconPosition: 'left' as const,
showAtPrefix: true,
stickerStyle: 'plain' as const,
gap: 6,
};
@@ -0,0 +1,306 @@
import React from 'react';
import { Film, Plus, X, ArrowRight, Volume2, Music, Camera } from 'lucide-react';
import { ExpressScene, DesignMD, CompanyProfile } from '../../../types';
import { SegmentCard } from './SegmentCard';
interface SceneComposerProps {
scenes: ExpressScene[];
activeSceneId: string | null;
onSelectScene: (sceneId: string) => void;
onAddScene: () => void;
onRemoveScene: (sceneId: string) => void;
designMD: DesignMD;
usesBrandAudio: boolean;
format: 'video' | 'image';
// Segment management
onAddSegment: (position: 'before' | 'after', source: 'brand' | 'form') => void;
onRemoveSegment: (position: 'before' | 'after') => void;
onUpdateSegment: (sceneId: string, updates: Partial<ExpressScene>) => void;
previewBrand: CompanyProfile | null;
}
/** Color mapping for scene types */
const TYPE_COLORS: Record<string, string> = {
intro: '#10b981',
content: '#8b5cf6',
outro: '#f43f5e',
transition: '#3b82f6',
};
const TYPE_ICONS: Record<string, React.ReactNode> = {
intro: <Film size={12} />,
content: <Camera size={12} />,
outro: <Film size={12} />,
transition: <ArrowRight size={12} />,
};
/**
* SceneComposer — Visual block composition for video templates.
*
* Layout: [Intro segment?] → [Content scene blocks + Add] → [Outro segment?]
* Below track: [+ Antes] button (if no intro) | [+ Después] button (if no outro)
*/
export const SceneComposer: React.FC<SceneComposerProps> = ({
scenes,
activeSceneId,
onSelectScene,
onAddScene,
onRemoveScene,
designMD,
usesBrandAudio,
format,
onAddSegment,
onRemoveSegment,
onUpdateSegment,
previewBrand,
}) => {
// Separate segments from content scenes
const introScene = scenes.find(s => s.type === 'intro') || null;
const outroScene = scenes.find(s => s.type === 'outro') || null;
const contentScenes = scenes.filter(s => s.type === 'content' || s.type === 'transition');
const totalDur = scenes.reduce((sum, s) => sum + s.durationSeconds, 0);
const contentDur = contentScenes.reduce((sum, s) => sum + s.durationSeconds, 0);
const hasAudio = usesBrandAudio && !!designMD.brandAudioUrl;
return (
<div className="bg-neutral-900/80 border border-neutral-800 rounded-2xl overflow-hidden">
{/* Header */}
<div className="flex items-center justify-between px-4 py-2.5 border-b border-neutral-800">
<div className="flex items-center gap-2">
<Film size={14} className="text-neutral-500" />
<h4 className="text-[10px] font-mono uppercase tracking-widest text-neutral-500">
Composición de Escenas
</h4>
</div>
<span className="text-[10px] font-mono text-neutral-600">
{totalDur.toFixed(1)}s · {scenes.length} escena{scenes.length !== 1 ? 's' : ''}
</span>
</div>
{/* Video Track */}
<div className="px-4 pt-3 pb-1">
<div className="flex items-center gap-0.5 text-[8px] text-neutral-500 mb-1.5 font-mono uppercase tracking-widest">
<Film size={9} /> {format === 'video' ? 'Video' : 'Imagen'}
</div>
<div className="flex items-stretch gap-2 min-h-[48px]">
{/* ── Intro Segment ── */}
{introScene && (
<>
<div
onClick={() => onSelectScene(introScene.id)}
className={`cursor-pointer rounded-xl transition-all ${activeSceneId === introScene.id ? 'ring-2 ring-emerald-500/60 ring-offset-1 ring-offset-neutral-950' : 'hover:ring-1 hover:ring-neutral-600/50'}`}
>
<SegmentCard
scene={introScene}
position="before"
designMD={designMD}
previewBrand={previewBrand}
onSourceChange={(source) => onUpdateSegment(introScene.id, {
segmentSource: source,
name: source === 'brand' ? 'Intro de marca' : 'Video de intro',
segmentFieldLabel: source === 'form' ? 'Video de intro' : undefined,
segmentFieldRequired: source === 'form' ? true : undefined,
})}
onDurationChange={(seconds) => onUpdateSegment(introScene.id, { durationSeconds: seconds })}
onLabelChange={(label) => onUpdateSegment(introScene.id, { segmentFieldLabel: label })}
onRequiredChange={(required) => onUpdateSegment(introScene.id, { segmentFieldRequired: required })}
onTransitionChange={(type) => onUpdateSegment(introScene.id, {
segmentTransition: { type, duration: introScene.segmentTransition?.duration || 10 },
})}
onRemove={() => onRemoveSegment('before')}
/>
</div>
{/* Arrow between intro and content */}
<div className="flex flex-col items-center justify-center shrink-0 px-0.5">
<div className="w-5 h-5 rounded-full bg-neutral-800/80 border border-neutral-700 flex items-center justify-center">
<ArrowRight size={8} className="text-neutral-400" />
</div>
</div>
</>
)}
{/* ── Content block: fixed "Contenido" badge ── */}
<div className="flex items-center gap-1 flex-1 min-w-0">
{contentScenes.map((scene, i) => {
const color = TYPE_COLORS[scene.type] || TYPE_COLORS.content;
const isActive = activeSceneId === scene.id;
const widthPct = contentDur > 0 ? (scene.durationSeconds / contentDur) * 100 : 25;
const canRemove = contentScenes.length > 1;
return (
<React.Fragment key={scene.id}>
<button
onClick={() => onSelectScene(scene.id)}
title={`${scene.name}${scene.durationSeconds}s · Click para editar`}
className={`h-12 rounded-lg flex flex-col items-center justify-center text-center transition-all relative overflow-hidden group cursor-pointer ${
isActive
? 'ring-2 ring-offset-1 ring-offset-neutral-900 scale-[1.02] z-10'
: 'hover:scale-[1.01]'
}`}
style={{
flex: `${Math.max(widthPct, 12)} 0 0`,
minWidth: '80px',
backgroundColor: isActive ? `${color}30` : `${color}15`,
border: `1px solid ${isActive ? color : `${color}40`}`,
['--tw-ring-color' as string]: color,
}}
>
{/* Shimmer on active */}
{isActive && (
<div
className="absolute inset-0 opacity-20"
style={{
background: `linear-gradient(90deg, transparent, ${color}40, transparent)`,
animation: 'shimmer 2s infinite',
}}
/>
)}
<span style={{ color }} className="mb-0.5 opacity-80 relative z-10">
{TYPE_ICONS[scene.type]}
</span>
<span className="text-[8px] font-bold tracking-wider text-white/80 relative z-10">
{scene.name.toUpperCase()}
</span>
{format === 'video' && (
<span className="text-[8px] font-mono text-neutral-400 relative z-10">
{scene.durationSeconds}s
</span>
)}
{/* Remove button */}
{canRemove && isActive && (
<button
onClick={(e) => { e.stopPropagation(); onRemoveScene(scene.id); }}
title="Eliminar escena"
className="absolute -top-1 -right-1 w-4 h-4 bg-red-500 text-white rounded-full flex items-center justify-center text-[8px] hover:bg-red-400 transition-colors z-20"
>
<X size={8} />
</button>
)}
</button>
{/* Transition dot between content scenes */}
{i < contentScenes.length - 1 && (
<div className="flex flex-col items-center shrink-0 px-0.5 gap-0.5">
<div className="w-5 h-5 rounded-full bg-neutral-800/80 border border-neutral-700 flex items-center justify-center">
<ArrowRight size={8} className="text-neutral-400" />
</div>
</div>
)}
</React.Fragment>
);
})}
{/* Add content scene button */}
<button
onClick={onAddScene}
title="Agregar escena de contenido"
className="h-12 min-w-[40px] rounded-lg border-2 border-dashed border-neutral-700 flex items-center justify-center text-neutral-500 hover:border-violet-500/50 hover:text-violet-400 hover:bg-violet-500/5 transition-all cursor-pointer shrink-0"
>
<Plus size={14} />
</button>
</div>
{/* ── Outro Segment ── */}
{outroScene && (
<>
{/* Arrow between content and outro */}
<div className="flex flex-col items-center justify-center shrink-0 px-0.5">
<div className="w-5 h-5 rounded-full bg-neutral-800/80 border border-neutral-700 flex items-center justify-center">
<ArrowRight size={8} className="text-neutral-400" />
</div>
</div>
<div
onClick={() => onSelectScene(outroScene.id)}
className={`cursor-pointer rounded-xl transition-all ${activeSceneId === outroScene.id ? 'ring-2 ring-emerald-500/60 ring-offset-1 ring-offset-neutral-950' : 'hover:ring-1 hover:ring-neutral-600/50'}`}
>
<SegmentCard
scene={outroScene}
position="after"
designMD={designMD}
previewBrand={previewBrand}
onSourceChange={(source) => onUpdateSegment(outroScene.id, {
segmentSource: source,
name: source === 'brand' ? 'Outro de marca' : 'Video de cierre',
segmentFieldLabel: source === 'form' ? 'Video de cierre' : undefined,
segmentFieldRequired: source === 'form' ? true : undefined,
})}
onDurationChange={(seconds) => onUpdateSegment(outroScene.id, { durationSeconds: seconds })}
onLabelChange={(label) => onUpdateSegment(outroScene.id, { segmentFieldLabel: label })}
onRequiredChange={(required) => onUpdateSegment(outroScene.id, { segmentFieldRequired: required })}
onTransitionChange={(type) => onUpdateSegment(outroScene.id, {
segmentTransition: { type, duration: outroScene.segmentTransition?.duration || 10 },
})}
onRemove={() => onRemoveSegment('after')}
/>
</div>
</>
)}
</div>
{/* ── Add segment buttons (below track) ── */}
{format === 'video' && (!introScene || !outroScene) && (
<div className="flex items-center gap-2 mt-2">
{!introScene && (
<button
onClick={() => onAddSegment('before', 'brand')}
title="Agregar contenido antes (intro)"
className="flex-1 h-8 rounded-lg border border-dashed border-neutral-700 flex items-center justify-center gap-1.5 text-neutral-500 hover:border-emerald-500/50 hover:text-emerald-400 hover:bg-emerald-500/5 transition-all cursor-pointer text-[9px] font-medium"
>
<Plus size={10} /> Antes
</button>
)}
{!outroScene && (
<button
onClick={() => onAddSegment('after', 'brand')}
title="Agregar contenido después (outro)"
className="flex-1 h-8 rounded-lg border border-dashed border-neutral-700 flex items-center justify-center gap-1.5 text-neutral-500 hover:border-rose-500/50 hover:text-rose-400 hover:bg-rose-500/5 transition-all cursor-pointer text-[9px] font-medium"
>
<Plus size={10} /> Después
</button>
)}
</div>
)}
</div>
{/* Audio Track (only for video) */}
{format === 'video' && (
<div className="px-4 pb-3 pt-1">
<div className="flex items-center gap-0.5 text-[8px] text-neutral-500 mb-1.5 font-mono uppercase tracking-widest">
<Volume2 size={9} /> Audio
</div>
<div
className={`w-full h-7 rounded-lg border flex items-center gap-2 px-3 ${
hasAudio
? 'border-neutral-800 bg-neutral-950'
: 'border-neutral-800 bg-transparent'
}`}
>
{hasAudio ? (
<>
<div className="flex items-end gap-[1px] h-4 flex-1">
{Array.from({ length: 48 }).map((_, i) => (
<div
key={i}
className="flex-1 rounded-full bg-neutral-600"
style={{
height: `${Math.max(2, Math.sin(i * 0.4) * 10 + Math.random() * 5 + 3)}px`,
}}
/>
))}
</div>
<span className="text-[9px] font-mono text-neutral-500 shrink-0">🔊 Auto</span>
</>
) : (
<span className="text-[9px] text-neutral-600 font-medium flex items-center gap-1.5 mx-auto">
<Music size={10} /> Sin audio de marca
</span>
)}
</div>
</div>
)}
</div>
);
};
@@ -0,0 +1,357 @@
import React from 'react';
import { Type, Image as ImageIcon, Plus, Trash2, Zap, Clock, Layers, Sparkles, Globe, Instagram, AtSign } from 'lucide-react';
import { ExpressScene, ExpressField, SceneLayout, BrandContentPiece, DesignMD } from '../../../types';
import { FieldInspector } from '../../ui/FieldInspector';
interface SceneConfiguratorProps {
scene: ExpressScene;
onUpdateScene: (updated: ExpressScene) => void;
brandContent: BrandContentPiece[];
designMD: DesignMD;
isVideo: boolean;
selectedFieldId?: string | null;
onSelectField?: (fieldId: string | null) => void;
}
/** Layout options with visual icons */
const LAYOUTS: { value: SceneLayout; label: string; icon: string }[] = [
{ value: 'fullscreen-media', label: 'Pantalla completa', icon: '📸' },
{ value: 'overlay', label: 'Overlay', icon: '🔲' },
{ value: 'split', label: 'Dividido', icon: '◫' },
{ value: 'media-left', label: 'Media izq.', icon: '◧' },
{ value: 'media-right', label: 'Media der.', icon: '◨' },
{ value: 'text-only', label: 'Solo texto', icon: '📝' },
];
/** Brand variables available for insertion */
const BRAND_VARIABLES: { source: ExpressField['brandSource']; label: string; icon: React.ReactNode; type: 'text' | 'media' | 'logo' }[] = [
{ source: 'brand-name', label: 'Nombre de Marca', icon: <Type size={10} />, type: 'text' },
{ source: 'tagline', label: 'Tagline / Eslogan', icon: <Sparkles size={10} />, type: 'text' },
{ source: 'logo', label: 'Logo', icon: <Zap size={10} />, type: 'logo' },
{ source: 'instagram', label: 'Instagram', icon: <Instagram size={10} />, type: 'text' },
{ source: 'tiktok', label: 'TikTok', icon: <AtSign size={10} />, type: 'text' },
{ source: 'twitter', label: 'X / Twitter', icon: <AtSign size={10} />, type: 'text' },
{ source: 'youtube', label: 'YouTube', icon: <AtSign size={10} />, type: 'text' },
{ source: 'website', label: 'Website', icon: <Globe size={10} />, type: 'text' },
];
/**
* SceneConfigurator — Config panel for the active scene in the Template Builder.
* Name, type, duration, layout, editable fields, brand assets, transition, background.
*/
export const SceneConfigurator: React.FC<SceneConfiguratorProps> = ({
scene,
onUpdateScene,
brandContent,
designMD,
isVideo,
selectedFieldId,
onSelectField,
}) => {
const updateField = (fieldId: string, updates: Partial<ExpressField>) => {
onUpdateScene({
...scene,
editableFields: scene.editableFields.map(f =>
f.id === fieldId ? { ...f, ...updates } : f
),
});
};
const addField = (type: ExpressField['type'], label: string, brandSource?: ExpressField['brandSource']) => {
const newField: ExpressField = {
id: `field-${Date.now()}`,
type,
label,
placeholder: label,
required: false,
brandSource,
position: { x: 50, y: 50, w: type === 'text' ? 80 : 60, h: type === 'text' ? 10 : 30 },
style: {
fontSize: type === 'text' ? 24 : undefined,
fontWeight: type === 'text' ? 400 : undefined,
textAlign: 'center',
opacity: 100,
},
};
onUpdateScene({ ...scene, editableFields: [...scene.editableFields, newField] });
};
const addBrandAsset = (asset: BrandContentPiece) => {
const newField: ExpressField = {
id: `field-asset-${asset.id}-${Date.now()}`,
type: asset.type === 'custom-image' ? 'media' : 'text',
label: asset.name,
placeholder: asset.content.text || asset.name,
required: false,
brandAssetId: asset.id,
position: { x: 50, y: 50, w: 40, h: 20 },
style: {
fontSize: asset.style.fontSize || 20,
fontWeight: 600,
textAlign: 'center',
opacity: 100,
},
};
onUpdateScene({ ...scene, editableFields: [...scene.editableFields, newField] });
};
const removeField = (fieldId: string) => {
onUpdateScene({
...scene,
editableFields: scene.editableFields.filter(f => f.id !== fieldId),
});
};
return (
<div className="space-y-4">
{/* Scene name + type */}
<div className="space-y-2">
<label className="text-[9px] text-neutral-500 uppercase tracking-wider font-semibold">Nombre de la escena</label>
<input
type="text"
value={scene.name}
onChange={(e) => onUpdateScene({ ...scene, name: e.target.value })}
className="w-full bg-neutral-800 border border-neutral-700 rounded-lg px-3 py-1.5 text-sm text-white focus:border-violet-500/50 focus:outline-none"
/>
</div>
{/* Type + Duration (video only) */}
{isVideo && (
<div className="flex gap-2">
<div className="flex-1 space-y-1">
<label className="text-[9px] text-neutral-500 uppercase tracking-wider font-semibold">Tipo</label>
<select
value={scene.type}
onChange={(e) => onUpdateScene({ ...scene, type: e.target.value as ExpressScene['type'] })}
className="w-full bg-neutral-800 border border-neutral-700 rounded-lg px-2 py-1.5 text-xs text-white focus:border-violet-500/50 focus:outline-none"
>
<option value="intro">Intro</option>
<option value="content">Contenido</option>
<option value="outro">Outro</option>
</select>
</div>
<div className="w-20 space-y-1">
<label className="text-[9px] text-neutral-500 uppercase tracking-wider font-semibold flex items-center gap-1">
<Clock size={8} /> Duración
</label>
<div className="flex items-center gap-1">
<input
type="number"
min={1}
max={30}
value={scene.durationSeconds}
onChange={(e) => onUpdateScene({ ...scene, durationSeconds: Math.max(1, parseInt(e.target.value) || 1) })}
className="w-full bg-neutral-800 border border-neutral-700 rounded-lg px-2 py-1.5 text-xs text-white text-center focus:border-violet-500/50 focus:outline-none"
/>
<span className="text-[9px] text-neutral-500">s</span>
</div>
</div>
</div>
)}
{/* Layout */}
<div className="space-y-1.5">
<label className="text-[9px] text-neutral-500 uppercase tracking-wider font-semibold flex items-center gap-1">
<Layers size={8} /> Layout
</label>
<div className="grid grid-cols-3 gap-1">
{LAYOUTS.map(l => (
<button
key={l.value}
onClick={() => onUpdateScene({ ...scene, layout: l.value })}
title={l.label}
className={`flex items-center gap-1 px-2 py-1.5 rounded-lg text-[9px] font-medium transition-all border ${
scene.layout === l.value
? 'bg-violet-600/15 border-violet-500/40 text-violet-300'
: 'bg-neutral-800 border-neutral-700 text-neutral-500 hover:text-neutral-300 hover:border-neutral-600'
}`}
>
<span>{l.icon}</span> {l.label}
</button>
))}
</div>
</div>
<hr className="border-neutral-800/50" />
{/* Editable Fields */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<label className="text-[9px] text-neutral-500 uppercase tracking-wider font-semibold">Campos editables</label>
<span className="text-[8px] text-neutral-600">{scene.editableFields.length} campos</span>
</div>
{scene.editableFields.map(field => {
const isSelected = selectedFieldId === field.id;
return (
<div
key={field.id}
onClick={() => onSelectField?.(field.id)}
className={`flex items-center gap-2 rounded-lg px-2.5 py-1.5 cursor-pointer transition-all ${
isSelected
? 'bg-violet-500/10 border border-violet-500/40 ring-1 ring-violet-500/20'
: 'bg-neutral-800/50 border border-neutral-700/50 hover:border-neutral-600'
}`}
>
<span className="text-[10px]">
{field.type === 'text' ? '📝' : field.type === 'media' ? '📷' : '⚡'}
</span>
<input
type="text"
value={field.label}
onChange={(e) => updateField(field.id, { label: e.target.value, placeholder: e.target.value })}
onClick={(e) => e.stopPropagation()}
className="flex-1 bg-transparent text-[10px] text-neutral-300 focus:outline-none"
/>
{field.brandSource && (
<span className="text-[7px] text-violet-400 bg-violet-500/10 px-1 py-0.5 rounded shrink-0">
{`{${field.brandSource}}`}
</span>
)}
{field.brandAssetId && (
<span className="text-[7px] text-amber-400 bg-amber-500/10 px-1 py-0.5 rounded shrink-0">
Asset
</span>
)}
<button
onClick={(e) => { e.stopPropagation(); removeField(field.id); }}
title="Quitar campo"
className="text-neutral-600 hover:text-red-400 transition-colors shrink-0"
>
<Trash2 size={10} />
</button>
</div>
);
})}
{/* Field Inspector (when a field is selected) */}
{selectedFieldId && (() => {
const field = scene.editableFields.find(f => f.id === selectedFieldId);
if (!field) return null;
const brandColors = [designMD.primaryColor, designMD.secondaryColor, designMD.textColor].filter(Boolean);
return (
<FieldInspector
position={field.position}
onPositionChange={(pos) => {
updateField(field.id, {
position: { ...field.position, ...pos },
});
}}
textStyle={field.type === 'text' ? {
fontSize: field.style.fontSize,
fontWeight: field.style.fontWeight,
fontFamily: field.style.fontFamily,
color: field.style.color,
textAlign: field.style.textAlign as 'left' | 'center' | 'right' | undefined,
opacity: field.style.opacity,
} : undefined}
onTextStyleChange={field.type === 'text' ? (style) => {
updateField(field.id, {
style: { ...field.style, ...style },
});
} : undefined}
fieldType={field.type as 'text' | 'media' | 'logo'}
fieldLabel={field.label}
brandFont={designMD.baseFont?.split(',')[0]?.replace(/"/g, '')}
brandColors={brandColors}
/>
);
})()}
{/* Add field buttons */}
<div className="flex gap-1">
<button
onClick={() => addField('text', 'Texto')}
title="Agregar campo de texto"
className="flex-1 flex items-center justify-center gap-1 py-1.5 rounded-lg border border-dashed border-neutral-700 text-[9px] text-neutral-500 hover:border-violet-500/50 hover:text-violet-400 transition-all"
>
<Plus size={8} /> Texto
</button>
<button
onClick={() => addField('media', 'Media')}
title="Agregar campo de media"
className="flex-1 flex items-center justify-center gap-1 py-1.5 rounded-lg border border-dashed border-neutral-700 text-[9px] text-neutral-500 hover:border-sky-500/50 hover:text-sky-400 transition-all"
>
<Plus size={8} /> Media
</button>
</div>
</div>
<hr className="border-neutral-800/50" />
{/* Brand Variables (social handles, name, etc.) */}
<div className="space-y-2">
<label className="text-[9px] text-neutral-500 uppercase tracking-wider font-semibold flex items-center gap-1">
<Zap size={8} className="text-violet-400" /> Variables de Marca
</label>
<div className="grid grid-cols-2 gap-1">
{BRAND_VARIABLES.map(v => (
<button
key={v.source}
onClick={() => addField(v.type, v.label, v.source)}
title={`Insertar {${v.source}}`}
className="flex items-center gap-1.5 px-2 py-1.5 rounded-lg bg-violet-500/5 border border-violet-500/15 text-[9px] text-violet-300 hover:bg-violet-500/10 hover:border-violet-500/30 transition-all"
>
{v.icon} {v.label}
</button>
))}
</div>
</div>
{/* Brand Content Assets */}
{brandContent.length > 0 && (
<>
<hr className="border-neutral-800/50" />
<div className="space-y-2">
<label className="text-[9px] text-neutral-500 uppercase tracking-wider font-semibold flex items-center gap-1">
<ImageIcon size={8} className="text-amber-400" /> Assets de Marca
</label>
<div className="grid grid-cols-2 gap-1">
{brandContent.map(asset => (
<button
key={asset.id}
onClick={() => addBrandAsset(asset)}
title={`Insertar asset: ${asset.name} (ID: ${asset.id})`}
className="flex items-center gap-1.5 px-2 py-1.5 rounded-lg bg-amber-500/5 border border-amber-500/15 text-[9px] text-amber-300 hover:bg-amber-500/10 hover:border-amber-500/30 transition-all text-left truncate"
>
{asset.thumbnail ? (
<img src={asset.thumbnail} alt="" className="w-4 h-4 rounded object-cover shrink-0" />
) : (
<div className="w-4 h-4 rounded bg-amber-500/20 shrink-0" />
)}
<span className="truncate">{asset.name}</span>
</button>
))}
</div>
</div>
</>
)}
<hr className="border-neutral-800/50" />
{/* Background */}
<div className="space-y-1.5">
<label className="text-[9px] text-neutral-500 uppercase tracking-wider font-semibold">Fondo</label>
<div className="flex gap-1">
{(['brand', 'solid', 'gradient', 'media'] as const).map(bg => (
<button
key={bg}
onClick={() => onUpdateScene({ ...scene, background: { type: bg } })}
title={`Fondo: ${bg}`}
className={`flex-1 py-1.5 rounded-lg text-[9px] font-medium transition-all border ${
scene.background?.type === bg
? 'bg-violet-600/15 border-violet-500/40 text-violet-300'
: 'bg-neutral-800 border-neutral-700 text-neutral-500 hover:text-neutral-300'
}`}
>
{bg === 'brand' ? '🎨 Marca' : bg === 'solid' ? '⬛ Sólido' : bg === 'gradient' ? '🌈 Grad' : '📷 Media'}
</button>
))}
</div>
</div>
</div>
);
};
@@ -0,0 +1,236 @@
import React from 'react';
import { X, Zap, FileText, Clock, AlertTriangle, Film } from 'lucide-react';
import { ExpressScene, DesignMD, CompanyProfile } from '../../../types';
interface SegmentCardProps {
scene: ExpressScene;
position: 'before' | 'after';
designMD: DesignMD;
previewBrand: CompanyProfile | null;
onSourceChange: (source: 'brand' | 'form') => void;
onDurationChange: (seconds: number) => void;
onLabelChange: (label: string) => void;
onRequiredChange: (required: boolean) => void;
onTransitionChange: (type: string) => void;
onRemove: () => void;
}
const TRANSITION_OPTIONS = [
{ value: 'none', label: 'Sin transición' },
{ value: 'fade', label: 'Fundido' },
{ value: 'slideUp', label: 'Deslizar ↑' },
{ value: 'slideDown', label: 'Deslizar ↓' },
{ value: 'slideLeft', label: 'Deslizar ←' },
{ value: 'slideRight', label: 'Deslizar →' },
{ value: 'scale', label: 'Escala' },
];
/**
* SegmentCard — Visual card for an intro/outro segment in the SceneComposer.
*
* Shows a source toggle (Marca/Formulario), duration, badge, and description.
* Matches the boceto design with dashed borders and pill-style toggles.
*/
export const SegmentCard: React.FC<SegmentCardProps> = ({
scene,
position,
designMD,
previewBrand,
onSourceChange,
onDurationChange,
onLabelChange,
onRequiredChange,
onTransitionChange,
onRemove,
}) => {
const isIntro = position === 'before';
const isBrand = scene.segmentSource === 'brand';
// Check if brand has the required video
const brandVideoUrl = isIntro ? designMD.introVideoUrl : designMD.outroVideoUrl;
const hasBrandVideo = !!brandVideoUrl;
const brandMissing = isBrand && !hasBrandVideo;
const borderColor = isBrand ? '#8b5cf6' : '#3b82f6';
const badgeBg = isBrand ? 'bg-violet-500/15' : 'bg-sky-500/15';
const badgeText = isBrand ? 'text-violet-300' : 'text-sky-300';
const badgeBorder = isBrand ? 'border-violet-500/30' : 'border-sky-500/30';
return (
<div
className="relative rounded-xl overflow-hidden transition-all group"
style={{
border: `1.5px ${isBrand ? 'solid' : 'dashed'} ${borderColor}40`,
backgroundColor: `${borderColor}08`,
minWidth: 160,
maxWidth: 200,
}}
>
{/* Header: title + duration + remove */}
<div className="flex items-center justify-between px-3 pt-2.5 pb-1">
<span className="text-[9px] font-bold text-white/80 tracking-wider uppercase">
{isIntro ? 'Antes' : 'Después'}
</span>
<div className="flex items-center gap-1.5">
{/* Duration */}
<div className="flex items-center gap-0.5" title="Duración del segmento">
<Clock size={8} className="text-neutral-500" />
<input
type="number"
value={scene.durationSeconds}
onChange={(e) => onDurationChange(Math.max(1, Number(e.target.value)))}
title="Duración en segundos"
className="w-8 bg-transparent text-[9px] font-mono text-neutral-400 text-right border-none outline-none"
step={0.5}
min={1}
max={30}
/>
<span className="text-[8px] text-neutral-600">s</span>
</div>
{/* Remove */}
<button
onClick={onRemove}
title={`Eliminar ${isIntro ? 'intro' : 'outro'}`}
className="w-4 h-4 rounded-full bg-red-500/20 text-red-400 hover:bg-red-500/40 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity"
>
<X size={8} />
</button>
</div>
</div>
{/* Source toggle */}
<div className="px-3 pb-2">
<div className="flex items-center bg-neutral-800/60 rounded-lg border border-neutral-700/40 p-0.5">
<button
onClick={() => onSourceChange('brand')}
title="Usar video de intro/outro de la marca (automático)"
className={`flex-1 px-2 py-1 rounded-md text-[8px] font-semibold transition-all ${
isBrand
? 'bg-violet-600/30 text-violet-200 shadow-sm'
: 'text-neutral-500 hover:text-neutral-300'
}`}
>
Marca
</button>
<button
onClick={() => onSourceChange('form')}
title="Pedir al productor que suba el video en el formulario"
className={`flex-1 px-2 py-1 rounded-md text-[8px] font-semibold transition-all ${
!isBrand
? 'bg-sky-600/30 text-sky-200 shadow-sm'
: 'text-neutral-500 hover:text-neutral-300'
}`}
>
Formulario
</button>
</div>
</div>
{/* Badge */}
<div className="px-3 pb-1.5 flex items-center justify-center">
<span className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[8px] font-bold border ${badgeBg} ${badgeText} ${badgeBorder}`}>
{isBrand ? (
<><Zap size={7} /> Auto</>
) : (
<><FileText size={7} /> Campo</>
)}
</span>
</div>
{/* Description */}
<div className="px-3 pb-3 text-center">
{isBrand ? (
<>
<p className="text-[9px] text-white/70 font-medium">
{isIntro ? 'Intro de la marca' : 'Outro de la marca'}
</p>
<p className="text-[8px] text-neutral-500 mt-0.5">
{previewBrand
? (hasBrandVideo ? 'desde el Design MD' : '')
: 'desde el Design MD'}
</p>
{brandMissing && previewBrand && (
<div className="flex items-center gap-1 justify-center mt-1.5 text-amber-400">
<AlertTriangle size={8} />
<span className="text-[7px] font-medium">
{previewBrand.name} no tiene {isIntro ? 'intro' : 'outro'}
</span>
</div>
)}
{hasBrandVideo && previewBrand && (
<div className="mt-1.5 rounded-md overflow-hidden border border-neutral-700/30" style={{ height: 36 }}>
<video
src={brandVideoUrl}
muted
className="w-full h-full object-cover"
style={{ pointerEvents: 'none' }}
/>
</div>
)}
</>
) : (
<>
<p className="text-[9px] text-white/70 font-medium">
{scene.segmentFieldLabel || (isIntro ? 'Video de intro' : 'Video de cierre')}
</p>
<p className="text-[8px] text-neutral-500 mt-0.5">
el productor lo sube
</p>
{/* Label config */}
<input
type="text"
value={scene.segmentFieldLabel || ''}
onChange={(e) => onLabelChange(e.target.value)}
placeholder="Etiqueta del campo"
title="Nombre del campo en el formulario"
className="mt-1.5 w-full bg-neutral-800/50 border border-neutral-700/40 rounded-md px-2 py-1 text-[8px] text-white placeholder-neutral-600 outline-none focus:border-sky-500/40"
/>
<label className="flex items-center gap-1 justify-center mt-1 cursor-pointer" title="¿Es obligatorio subir este video?">
<input
type="checkbox"
checked={scene.segmentFieldRequired ?? true}
onChange={(e) => onRequiredChange(e.target.checked)}
className="w-3 h-3 rounded bg-neutral-800 border-neutral-700 accent-sky-500"
/>
<span className="text-[7px] text-neutral-500">Obligatorio</span>
</label>
</>
)}
</div>
{/* Transition selector */}
<div className="px-3 pb-2.5 border-t border-neutral-800/40 pt-2">
<div className="flex items-center gap-1">
<Film size={7} className="text-neutral-500 shrink-0" />
<select
value={scene.segmentTransition?.type || 'fade'}
onChange={(e) => onTransitionChange(e.target.value)}
title="Transición del segmento"
className="flex-1 bg-transparent text-[8px] text-neutral-400 border-none outline-none cursor-pointer"
>
{TRANSITION_OPTIONS.map(t => (
<option key={t.value} value={t.value}>{t.label}</option>
))}
</select>
</div>
</div>
{/* Position summary */}
{(scene.segmentVideoX != null || scene.segmentVideoW != null) && (
<div className="px-3 pb-2 flex items-center justify-center gap-1 text-[7px] text-emerald-400/60 font-mono">
<span>📐</span>
<span>
{(scene.segmentVideoX ?? 50).toFixed(0)},{(scene.segmentVideoY ?? 50).toFixed(0)}
</span>
<span></span>
<span>
{(scene.segmentVideoW ?? 100).toFixed(0)}×{(scene.segmentVideoH ?? 100).toFixed(0)}
</span>
{scene.segmentVideoFit && scene.segmentVideoFit !== 'cover' && (
<span className="text-emerald-400/40">({scene.segmentVideoFit})</span>
)}
</div>
)}
</div>
);
};
@@ -0,0 +1,275 @@
import React, { useRef, useCallback, useMemo } from 'react';
import { Move, Maximize2, Film, AlertTriangle, FileText } from 'lucide-react';
import { ExpressScene, DesignMD, CompanyProfile } from '../../../types';
import { getAspectDimensions } from '../../../utils/expressCompiler';
import { useDragResize } from '../../../hooks/useDragResize';
const SEGMENT_VIDEO_ID = 'segment-video-frame';
interface SegmentVideoFrameProps {
scene: ExpressScene;
designMD: DesignMD;
previewBrand: CompanyProfile | null;
aspectRatio: string;
onPositionChange: (updates: Partial<ExpressScene>) => void;
}
/**
* SegmentVideoFrame — Draggable/resizable video element for intro/outro segments.
*
* Rendered on the BuilderCanvas when the active scene is a segment (intro/outro).
* Uses the shared `useDragResize` hook per AGENTS.md rules.
* Shows the brand video thumbnail or a placeholder depending on source and availability.
*/
export const SegmentVideoFrame: React.FC<SegmentVideoFrameProps> = ({
scene,
designMD,
previewBrand,
aspectRatio,
onPositionChange,
}) => {
const containerRef = useRef<HTMLDivElement>(null);
const isIntro = scene.type === 'intro';
const isBrand = scene.segmentSource === 'brand';
// Current position (defaults to fullscreen centered)
const x = scene.segmentVideoX ?? 50;
const y = scene.segmentVideoY ?? 50;
const w = scene.segmentVideoW ?? 100;
const h = scene.segmentVideoH ?? 100;
const fit = scene.segmentVideoFit ?? (isBrand
? (isIntro ? (designMD.introVideoFit || 'cover') : (designMD.outroVideoFit || 'cover'))
: 'cover');
// Brand video URL
const videoUrl = isBrand
? (isIntro ? designMD.introVideoUrl : designMD.outroVideoUrl)
: undefined;
const hasVideo = !!videoUrl;
const dimensions = getAspectDimensions(aspectRatio);
// ── Drag/resize hook ──
const {
startDrag,
startResize,
handlePointerMove,
handlePointerUp,
isDragging,
snapGuides,
} = useDragResize({
containerRef: containerRef as React.RefObject<HTMLElement>,
onMove: useCallback((_id: string, newX: number, newY: number) => {
onPositionChange({ segmentVideoX: newX, segmentVideoY: newY });
}, [onPositionChange]),
onResize: useCallback((_id: string, newW: number, newH: number) => {
onPositionChange({ segmentVideoW: newW, segmentVideoH: newH });
}, [onPositionChange]),
snapLines: [50],
snapThreshold: 1.5,
});
// Object-fit toggle
const fitOptions: Array<{ value: 'cover' | 'contain' | 'fill'; label: string }> = [
{ value: 'cover', label: 'Cover' },
{ value: 'contain', label: 'Contain' },
{ value: 'fill', label: 'Fill' },
];
// Background color based on scene type
const bgColor = useMemo(() => {
const bg = scene.background;
if (!bg) return designMD.secondaryColor;
switch (bg.type) {
case 'brand': return designMD.secondaryColor;
case 'solid': return bg.value || '#1a1a1a';
default: return designMD.secondaryColor;
}
}, [scene.background, designMD]);
return (
<div className="flex-1 flex flex-col items-center justify-center bg-neutral-950 p-4 overflow-hidden relative min-h-0">
{/* Dot pattern */}
<div
className="absolute inset-0 opacity-[0.02]"
style={{ backgroundImage: 'radial-gradient(circle at 1px 1px, white 1px, transparent 0)', backgroundSize: '30px 30px' }}
/>
{/* Mode indicator */}
<div className="absolute top-4 left-4 flex items-center gap-2 z-20">
<Film size={12} className="text-emerald-400" />
<span className="text-[9px] font-bold text-emerald-300 uppercase tracking-wider">
{isIntro ? 'Posición — Intro' : 'Posición — Outro'}
</span>
<span className={`text-[8px] px-1.5 py-0.5 rounded-full font-medium ${
isBrand ? 'bg-violet-500/15 text-violet-300' : 'bg-sky-500/15 text-sky-300'
}`}>
{isBrand ? '⚡ Marca' : '📋 Formulario'}
</span>
</div>
{/* Canvas wrapper */}
<div
ref={containerRef}
className="relative rounded-xl overflow-hidden shadow-2xl shadow-black/50 border border-neutral-800/40 select-none shrink-0"
style={{
...(aspectRatio === '9:16' || aspectRatio === '4:5'
? { height: 'calc(100% - 80px)', maxWidth: '90%' }
: {
width: aspectRatio === '1:1' ? 360 : 440,
maxHeight: 'calc(100% - 80px)',
}),
aspectRatio: `${dimensions.w} / ${dimensions.h}`,
backgroundColor: bgColor,
}}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
onPointerCancel={handlePointerUp}
>
{/* Center crosshair (subtle) */}
<div className="absolute left-1/2 top-0 bottom-0 w-px bg-white/[0.03] pointer-events-none z-0" />
<div className="absolute top-1/2 left-0 right-0 h-px bg-white/[0.03] pointer-events-none z-0" />
{/* Snap guides */}
{snapGuides.x !== undefined && (
<div
className="absolute top-0 bottom-0 pointer-events-none z-50"
style={{ left: `${snapGuides.x}%`, width: '1px', background: 'rgba(16, 185, 129, 0.5)', borderLeft: '1px dashed rgba(16, 185, 129, 0.6)' }}
/>
)}
{snapGuides.y !== undefined && (
<div
className="absolute left-0 right-0 pointer-events-none z-50"
style={{ top: `${snapGuides.y}%`, height: '1px', background: 'rgba(16, 185, 129, 0.5)', borderTop: '1px dashed rgba(16, 185, 129, 0.6)' }}
/>
)}
{/* ── Video Frame Element ── */}
<div
className="absolute transition-shadow"
style={{
left: `${x - w / 2}%`,
top: `${y - h / 2}%`,
width: `${w}%`,
height: `${h}%`,
zIndex: 10,
}}
>
<div
className={`w-full h-full rounded-md flex flex-col items-center justify-center overflow-hidden cursor-grab active:cursor-grabbing transition-all ${
isDragging ? 'scale-[1.01] shadow-xl' : ''
}`}
style={{
border: '2px solid rgba(16, 185, 129, 0.6)',
outline: '2px solid rgba(16, 185, 129, 0.3)',
outlineOffset: '2px',
backgroundColor: 'rgba(16, 185, 129, 0.08)',
}}
onPointerDown={(e) => {
e.stopPropagation();
startDrag(e, SEGMENT_VIDEO_ID, { x, y, w, h });
}}
>
{/* Video content */}
{isBrand && hasVideo ? (
<video
src={videoUrl}
muted
loop
autoPlay
playsInline
className="w-full h-full pointer-events-none"
style={{ objectFit: fit }}
/>
) : isBrand && !hasVideo ? (
<div className="flex flex-col items-center gap-2 pointer-events-none p-4">
<AlertTriangle size={20} className="text-amber-400" />
<span className="text-[9px] text-amber-300/80 text-center font-medium">
{previewBrand
? `${previewBrand.name} no tiene ${isIntro ? 'intro' : 'outro'}`
: `Sin ${isIntro ? 'intro' : 'outro'} de marca`}
</span>
<span className="text-[8px] text-neutral-500 text-center">
Puedes posicionar el marco ahora se aplicará cuando la marca tenga video
</span>
</div>
) : (
/* Form source */
<div className="flex flex-col items-center gap-2 pointer-events-none p-4">
<FileText size={20} className="text-sky-400" />
<span className="text-[9px] text-sky-300/80 text-center font-medium">
{scene.segmentFieldLabel || (isIntro ? 'Video de intro' : 'Video de cierre')}
</span>
<span className="text-[8px] text-neutral-500 text-center">
El productor subirá este video
</span>
</div>
)}
{/* Badge */}
<div
className="absolute -top-2.5 left-2 flex items-center gap-0.5 px-1.5 py-0.5 rounded text-[7px] font-bold tracking-wider pointer-events-none"
style={{
backgroundColor: 'rgba(16, 185, 129, 0.15)',
color: '#6ee7b7',
border: '1px solid rgba(16, 185, 129, 0.3)',
}}
>
<Film size={7} /> {isIntro ? 'INTRO' : 'OUTRO'}
</div>
{/* Position readout */}
<div className="absolute -bottom-5 left-1/2 -translate-x-1/2 flex items-center gap-1 text-[7px] text-emerald-300/60 font-mono whitespace-nowrap pointer-events-none">
<Move size={7} /> {x.toFixed(0)},{y.toFixed(0)}
<Maximize2 size={7} className="ml-1" /> {w.toFixed(0)}×{h.toFixed(0)}
</div>
</div>
{/* Resize handle */}
<div
className="absolute -bottom-1.5 -right-1.5 w-3 h-3 border-2 border-neutral-900 rounded-sm cursor-nwse-resize z-40 hover:opacity-80 transition-colors"
style={{ backgroundColor: '#10b981' }}
onPointerDown={(e) => startResize(e, SEGMENT_VIDEO_ID, { x, y, w, h })}
title="Redimensionar video"
/>
</div>
</div>
{/* Object-fit controls below canvas */}
<div className="mt-3 flex items-center gap-2 z-10">
<span className="text-[8px] text-neutral-500 font-mono uppercase tracking-wider">Ajuste:</span>
<div className="flex items-center bg-neutral-800/60 rounded-lg border border-neutral-700/50 p-0.5">
{fitOptions.map(opt => (
<button
key={opt.value}
onClick={() => onPositionChange({ segmentVideoFit: opt.value })}
title={`Ajuste de video: ${opt.label}`}
className={`px-2.5 py-1 rounded-md text-[8px] font-semibold transition-all ${
fit === opt.value
? 'bg-emerald-600/30 text-emerald-200 shadow-sm'
: 'text-neutral-500 hover:text-neutral-300'
}`}
>
{opt.label}
</button>
))}
</div>
{/* Reset button */}
<button
onClick={() => onPositionChange({
segmentVideoX: 50,
segmentVideoY: 50,
segmentVideoW: 100,
segmentVideoH: 100,
segmentVideoFit: 'cover',
})}
title="Restablecer posición a pantalla completa"
className="px-2 py-1 rounded-md text-[8px] text-neutral-500 hover:text-neutral-300 bg-neutral-800/40 border border-neutral-700/30 transition-colors"
>
Reset
</button>
</div>
</div>
);
};
@@ -0,0 +1,692 @@
import React, { useState, useCallback, useMemo } from 'react';
import {
ArrowLeft, Save, Video, Image as ImageIcon,
Eye, FileText, Hash, Briefcase, FlaskConical, Zap,
} from 'lucide-react';
import {
ExpressTemplate, ExpressScene, DesignMD, CompanyProfile,
TemplateField, ExpressField,
} from '../../../types';
import {
TemplateBuilderProvider, useTemplateBuilder, useSceneFieldsMap,
TemplateMeta, migrateExpressFields,
} from '../../../context/TemplateBuilderContext';
import { FieldSchemaPanel } from './FieldSchemaPanel';
import { FieldConfigPanel } from './FieldConfigPanel';
import { BuilderCanvas } from './BuilderCanvas';
import { FormPreviewPanel } from './FormPreviewPanel';
import { SceneComposer } from './SceneComposer';
import { TemplateFieldInput } from '../../shared/TemplateFieldInput';
import { LivePreviewCanvas } from '../../shared/LivePreviewCanvas';
interface TemplateBuilderProps {
company?: CompanyProfile;
designMD?: DesignMD;
availableBrands?: CompanyProfile[];
onSave: (template: ExpressTemplate) => void;
onBack: () => void;
editingTemplate?: ExpressTemplate | null;
initialFormat?: 'video' | 'image';
initialAspect?: ExpressTemplate['aspectRatio'];
}
const CATEGORIES: { value: ExpressTemplate['category']; label: string; icon: string }[] = [
{ value: 'social', label: 'Social', icon: '📱' },
{ value: 'ad', label: 'Publicidad', icon: '🎯' },
{ value: 'promo', label: 'Promo', icon: '🚀' },
{ value: 'story', label: 'Historia', icon: '💬' },
{ value: 'announcement', label: 'Anuncio', icon: '📢' },
];
function createDefaultScene(format: 'video' | 'image'): ExpressScene {
const bgType = format === 'video' ? 'video' : 'image';
const bgLabel = format === 'video' ? 'Video de fondo' : 'Imagen de fondo';
const now = Date.now();
return {
id: `scene-${now}`,
type: 'content',
name: 'Nueva Escena',
durationSeconds: 5,
layout: 'overlay',
editableFields: [],
fields: [
// Background — always index 0 (bottom z-index)
{
id: `field-bg-${now}`,
nature: 'editable-slot' as const,
type: bgType,
label: bgLabel,
required: true,
content: bgLabel,
position: { x: 50, y: 50, w: 100, h: 100 },
style: { opacity: 100 },
formOrder: 0,
isBackground: true,
},
// Title — on top
{
id: `field-title-${now + 1}`,
nature: 'editable-slot' as const,
type: 'text' as const,
label: 'Título',
required: true,
content: 'Escribe aquí',
position: { x: 50, y: 45, w: 80, h: 15 },
style: { fontSize: 36, fontWeight: 700, textAlign: 'center' as const, opacity: 100 },
formOrder: 1,
},
],
background: { type: 'brand' },
transition: { type: 'fade', duration: 10 },
};
}
/**
* TemplateBuilder — Redesigned visual template editor.
*
* Uses TemplateBuilderContext instead of EditorProvider.
* Layout: FieldSchemaPanel (left) | Canvas or FormPreview (center) | FieldConfigPanel (right)
*/
export const TemplateBuilder: React.FC<TemplateBuilderProps> = (props) => {
const format = props.editingTemplate?.format || props.initialFormat || 'video';
const initialScenes = useMemo(() => {
if (props.editingTemplate?.scenes?.length) return props.editingTemplate.scenes;
return [createDefaultScene(format)];
}, []);
const initialMeta: TemplateMeta = useMemo(() => ({
name: props.editingTemplate?.name || '',
description: props.editingTemplate?.description || '',
category: props.editingTemplate?.category || 'social',
aspectRatio: props.editingTemplate?.aspectRatio || props.initialAspect || '9:16',
format,
usesBrandAudio: props.editingTemplate?.usesBrandAudio ?? true,
}), []);
return (
<TemplateBuilderProvider
designMD={props.designMD}
company={props.company}
availableBrands={props.availableBrands}
initialScenes={initialScenes}
initialMeta={initialMeta}
>
<TemplateBuilderInner
onSave={props.onSave}
onBack={props.onBack}
editingTemplate={props.editingTemplate}
/>
</TemplateBuilderProvider>
);
};
/* ═══════════════════════════════════════════════════════════════
* Inner component — lives inside TemplateBuilderProvider
* ═══════════════════════════════════════════════════════════════ */
interface InnerProps {
onSave: (template: ExpressTemplate) => void;
onBack: () => void;
editingTemplate?: ExpressTemplate | null;
}
const TemplateBuilderInner: React.FC<InnerProps> = ({
onSave,
onBack,
editingTemplate,
}) => {
const {
scenes,
setScenes,
activeSceneId,
setActiveSceneId,
activeScene,
viewMode,
setViewMode,
templateMeta,
setTemplateMeta,
editableSlotCount,
totalFieldCount,
selectedFieldId,
previewBrand,
setPreviewBrand,
availableBrands,
resolvedDesignMD,
resolvedCompany,
fields,
testFieldData,
setTestFieldData,
testMediaFits,
setTestMediaFits,
testContainBgColors,
setTestContainBgColors,
// Segment management
addSegment,
removeSegment,
updateSegment,
introScene,
outroScene,
} = useTemplateBuilder();
const sceneFieldsMap = useSceneFieldsMap();
const [nameError, setNameError] = useState(false);
// ── Scene callbacks ──
const handleAddScene = useCallback(() => {
const newScene = createDefaultScene(templateMeta.format);
setScenes(prev => [...prev, newScene]);
setActiveSceneId(newScene.id);
}, [setScenes, setActiveSceneId, templateMeta.format]);
const handleRemoveScene = useCallback((sceneId: string) => {
setScenes(prev => {
const next = prev.filter(s => s.id !== sceneId);
if (activeSceneId === sceneId) {
setActiveSceneId(next[0]?.id || null);
}
return next;
});
}, [activeSceneId, setScenes, setActiveSceneId]);
const handleUpdateScene = useCallback((updated: ExpressScene) => {
setScenes(prev => prev.map(s => s.id === updated.id ? updated : s));
}, [setScenes]);
// ── Save ──
const handleSave = useCallback(() => {
if (!templateMeta.name.trim()) {
setNameError(true);
setTimeout(() => setNameError(false), 3000);
return;
}
// Convert all scene fields back to ExpressField format for backward compat
const updatedScenes = scenes.map(scene => {
const templateFields = sceneFieldsMap[scene.id] || [];
return {
...scene,
fields: templateFields,
editableFields: templateFieldsToExpressFields(templateFields),
};
});
const template: ExpressTemplate = {
id: editingTemplate?.id || `tpl-${Date.now()}`,
name: templateMeta.name,
description: templateMeta.description,
category: templateMeta.category,
icon: CATEGORIES.find(c => c.value === templateMeta.category)?.icon || '📐',
aspectRatio: templateMeta.aspectRatio,
format: templateMeta.format,
scenes: updatedScenes,
usesBrandAudio: templateMeta.format === 'video',
isCustom: true,
createdAt: editingTemplate?.createdAt || new Date().toISOString(),
};
onSave(template);
}, [templateMeta, scenes, sceneFieldsMap, editingTemplate, onSave]);
// ── Build a temporary ExpressTemplate from current state (for LivePreviewCanvas) ──
const buildCurrentTemplate = useCallback((): ExpressTemplate => {
const updatedScenes = scenes.map(scene => {
const templateFields = sceneFieldsMap[scene.id] || [];
return {
...scene,
fields: templateFields,
editableFields: templateFieldsToExpressFields(templateFields),
};
});
return {
id: editingTemplate?.id || 'tpl-preview',
name: templateMeta.name || 'Preview',
description: templateMeta.description,
category: templateMeta.category,
icon: CATEGORIES.find(c => c.value === templateMeta.category)?.icon || '📐',
aspectRatio: templateMeta.aspectRatio,
format: templateMeta.format,
scenes: updatedScenes,
usesBrandAudio: false,
isCustom: true,
};
}, [templateMeta, scenes, sceneFieldsMap, editingTemplate]);
return (
<div className="flex-1 flex overflow-hidden bg-neutral-950">
{/* ── Left: Field Schema Panel (full height) ── */}
<FieldSchemaPanel />
{/* ── Center: Canvas + Scene Composer ── */}
<div className="relative flex-1 flex flex-col min-h-0">
{/* Top bar — all metadata inline */}
<div className="h-11 flex items-center gap-2 px-3 border-b border-neutral-800/60 shrink-0 bg-neutral-950/80 backdrop-blur-sm z-10">
{/* Left: back */}
<button
onClick={onBack}
title="Volver a plantillas"
className="text-neutral-400 hover:text-white transition-colors shrink-0"
>
<ArrowLeft size={14} />
</button>
{/* Divider */}
<div className="w-px h-5 bg-neutral-800 shrink-0" />
{/* Name — inline editable */}
<input
type="text"
value={templateMeta.name}
onChange={(e) => { setTemplateMeta(prev => ({ ...prev, name: e.target.value })); setNameError(false); }}
placeholder="Nombre de plantilla..."
className={`bg-transparent border-none text-sm font-semibold text-white placeholder-neutral-600 focus:outline-none min-w-0 w-40 truncate transition-colors ${
nameError ? 'text-red-400 placeholder-red-500/50' : ''
}`}
/>
{/* Counter badge */}
<div className="flex items-center gap-1 px-1.5 py-0.5 rounded bg-sky-500/10 border border-sky-500/20 shrink-0">
<Hash size={8} className="text-sky-400" />
<span className="text-[8px] text-sky-300 font-mono">
{editableSlotCount}/{totalFieldCount}
</span>
</div>
{/* Spacer */}
<div className="flex-1" />
{/* Center: View mode toggle + aspect + format */}
<div className="flex items-center gap-2 shrink-0">
{/* View mode toggle */}
<div className="flex items-center bg-neutral-800/60 rounded-lg border border-neutral-700/50 p-0.5">
<button
onClick={() => setViewMode('design')}
title="Vista de diseño"
className={`flex items-center gap-1 px-2 py-1 rounded-md text-[9px] font-medium transition-all ${
viewMode === 'design'
? 'bg-neutral-700 text-white shadow-sm'
: 'text-neutral-500 hover:text-neutral-300'
}`}
>
<Eye size={10} /> Diseño
</button>
<button
onClick={() => setViewMode('form-preview')}
title="Vista de formulario"
className={`flex items-center gap-1 px-2 py-1 rounded-md text-[9px] font-medium transition-all ${
viewMode === 'form-preview'
? 'bg-neutral-700 text-white shadow-sm'
: 'text-neutral-500 hover:text-neutral-300'
}`}
>
<FileText size={10} /> Formulario
</button>
<button
onClick={() => setViewMode('test-data')}
title="Probar con datos de ejemplo"
className={`flex items-center gap-1 px-2 py-1 rounded-md text-[9px] font-medium transition-all ${
viewMode === 'test-data'
? 'bg-emerald-700 text-white shadow-sm'
: 'text-neutral-500 hover:text-neutral-300'
}`}
>
<FlaskConical size={10} /> Probar
</button>
</div>
{/* Brand preview selector */}
<div className="flex items-center bg-neutral-800/60 rounded-lg border border-neutral-700/50 p-0.5">
<Briefcase size={10} className={previewBrand ? 'text-violet-400 ml-1.5' : 'text-neutral-500 ml-1.5'} />
<select
value={previewBrand?.id ?? ''}
onChange={(e) => {
const brand = availableBrands.find(b => b.id === e.target.value) ?? null;
setPreviewBrand(brand);
}}
title="Ver con marca"
className="bg-transparent text-[9px] font-medium text-neutral-300 border-none focus:outline-none cursor-pointer px-1 py-1 appearance-none pr-4"
style={{ backgroundImage: `url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='8' height='8' viewBox='0 0 24 24' fill='none' stroke='%23999' stroke-width='2'%3E%3Cpolyline points='6 9 12 15 18 9'%3E%3C/polyline%3E%3C/svg%3E")`, backgroundRepeat: 'no-repeat', backgroundPosition: 'right 4px center' }}
>
<option value="">Sin marca</option>
{availableBrands.map(b => (
<option key={b.id} value={b.id}>{b.name}</option>
))}
</select>
</div>
{/* Aspect ratio */}
<span className="text-[10px] font-bold text-neutral-400">
{templateMeta.aspectRatio}
</span>
{/* Format badge */}
<div className={`inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-[8px] font-bold ${
templateMeta.format === 'video'
? 'bg-violet-500/15 text-violet-300 border border-violet-500/20'
: 'bg-sky-500/15 text-sky-300 border border-sky-500/20'
}`}>
{templateMeta.format === 'video' ? <Video size={9} /> : <ImageIcon size={9} />}
{templateMeta.format === 'video' ? 'VIDEO' : 'IMG'}
</div>
</div>
{/* Divider */}
<div className="w-px h-5 bg-neutral-800 shrink-0" />
{/* Right: category + save */}
<div className="flex items-center gap-1.5 shrink-0">
{/* Category pills (compact) */}
{CATEGORIES.map(c => (
<button
key={c.value}
onClick={() => setTemplateMeta(prev => ({ ...prev, category: c.value }))}
title={c.label}
className={`px-1.5 py-0.5 rounded text-[8px] transition-all border ${
templateMeta.category === c.value
? 'bg-violet-600/15 border-violet-500/40 text-violet-300'
: 'bg-transparent border-transparent text-neutral-600 hover:text-neutral-400'
}`}
>
{c.icon}
</button>
))}
{/* Save button */}
<button
onClick={handleSave}
title={!templateMeta.name.trim() ? 'Dale un nombre primero' : 'Guardar plantilla'}
className={`flex items-center gap-1 px-2.5 py-1.5 rounded-lg text-white text-[10px] font-semibold transition-all shadow-lg ${
!templateMeta.name.trim()
? 'bg-gradient-to-r from-amber-600 to-orange-600 hover:from-amber-500 hover:to-orange-500 shadow-amber-900/30'
: 'bg-gradient-to-r from-emerald-600 to-teal-600 hover:from-emerald-500 hover:to-teal-500 shadow-emerald-900/30'
}`}
>
<Save size={12} />
Guardar
</button>
</div>
</div>
{/* Canvas row: canvas + optional config panel */}
<div className="flex-1 flex min-h-0 overflow-hidden">
{/* Canvas / Form Preview / Test Data */}
<div className="flex-1 min-w-0 flex flex-col">
{viewMode === 'design' ? (
<BuilderCanvas />
) : viewMode === 'form-preview' ? (
<FormPreviewPanel />
) : (
/* test-data mode: split form + live preview */
<div className="flex-1 flex min-h-0 overflow-hidden">
{/* Test data form */}
<TestDataFormPanel />
{/* Live Remotion preview */}
<div className="flex-1 min-w-0 bg-neutral-950 relative">
{/* Subtle grid background */}
<div
className="absolute inset-0 opacity-[0.02]"
style={{ backgroundImage: 'radial-gradient(circle at 1px 1px, white 1px, transparent 0)', backgroundSize: '40px 40px' }}
/>
<LivePreviewCanvas
template={buildCurrentTemplate()}
fieldData={testFieldData}
brand={resolvedCompany}
designMD={resolvedDesignMD}
mediaFits={testMediaFits}
containBgColors={testContainBgColors}
activeSceneId={activeSceneId}
onSceneChange={setActiveSceneId}
/>
</div>
</div>
)}
</div>
{/* Right: Field Config Panel (design/form-preview modes only, not in segment mode) */}
{selectedFieldId && viewMode !== 'test-data' && !activeScene?.segmentSource && (
<aside className="w-64 bg-neutral-900 border-l border-neutral-800/60 shrink-0 overflow-y-auto custom-scrollbar" onClick={(e) => e.stopPropagation()}>
<FieldConfigPanel />
</aside>
)}
</div>
{/* Scene Composer (video only) — always full width below canvas row */}
{templateMeta.format === 'video' && (
<div className="shrink-0 p-3 border-t border-neutral-800/60 bg-neutral-900/50">
<SceneComposer
scenes={scenes}
activeSceneId={activeSceneId}
onSelectScene={setActiveSceneId}
onAddScene={handleAddScene}
onRemoveScene={handleRemoveScene}
designMD={resolvedDesignMD}
usesBrandAudio={templateMeta.usesBrandAudio}
format={templateMeta.format}
onAddSegment={addSegment}
onRemoveSegment={removeSegment}
onUpdateSegment={updateSegment}
previewBrand={previewBrand}
/>
</div>
)}
</div>
</div>
);
};
// ── Helper: Convert TemplateField[] to legacy ExpressField[] for backward compat ──
function templateFieldsToExpressFields(fields: TemplateField[]): ExpressField[] {
return fields.map((f): ExpressField => ({
id: f.id,
type: f.type === 'video' ? 'media' : f.type === 'image' ? (f.brandSource === 'logo' ? 'logo' : 'media') : f.type === 'shape' ? 'shape' : 'text',
label: f.label,
placeholder: f.content || f.label,
required: f.required,
brandSource: f.brandSource,
brandAssetId: f.brandAssetId,
position: { x: f.position.x, y: f.position.y, w: f.position.w, h: f.position.h },
style: {
fontSize: f.style.fontSize,
fontWeight: f.style.fontWeight,
fontFamily: f.style.fontFamily,
textAlign: f.style.textAlign,
color: f.style.color,
opacity: f.style.opacity,
shapeType: f.style.shapeType,
shapeFill: f.style.shapeFill,
shapeStroke: f.style.shapeStroke,
shapeStrokeWidth: f.style.shapeStrokeWidth,
shapeCornerRadius: f.style.shapeCornerRadius,
},
}));
}
// ── TestDataFormPanel — Form for entering test data in test-data view mode ──
/** Resolve brand variable preview for read-only display */
function resolveBrandTestValue(field: TemplateField, company: CompanyProfile, designMD: DesignMD): string {
if (!field.brandSource) return '';
switch (field.brandSource) {
case 'brand-name': return company.name || designMD.brandName || '';
case 'tagline': return company.tagline || '';
case 'logo': return '(Logo de marca)';
case 'instagram': return company.socialLinks?.instagram || '';
case 'tiktok': return company.socialLinks?.tiktok || '';
case 'twitter': return company.socialLinks?.x || '';
case 'youtube': return company.socialLinks?.youtube || '';
case 'website': return company.socialLinks?.website || '';
default: return '';
}
}
const TestDataFormPanel: React.FC = () => {
const {
fields,
scenes,
activeSceneId,
setActiveSceneId,
resolvedDesignMD: designMD,
resolvedCompany: company,
testFieldData,
setTestFieldData,
testMediaFits,
setTestMediaFits,
testContainBgColors,
setTestContainBgColors,
} = useTemplateBuilder();
const sceneFieldsMap = useSceneFieldsMap();
// Get all editable slots across all scenes
const allEditableSlots = useMemo(() => {
const slots: { field: TemplateField; sceneId: string; sceneName: string }[] = [];
for (const scene of scenes) {
const sceneFields = sceneFieldsMap[scene.id] || [];
for (const f of sceneFields) {
if (f.nature === 'editable-slot') {
slots.push({ field: f, sceneId: scene.id, sceneName: scene.name });
}
}
}
return slots.sort((a, b) => a.field.formOrder - b.field.formOrder);
}, [scenes, sceneFieldsMap]);
const brandVars = useMemo(() => {
const vars: TemplateField[] = [];
for (const scene of scenes) {
const sceneFields = sceneFieldsMap[scene.id] || [];
for (const f of sceneFields) {
if (f.nature === 'brand-variable') vars.push(f);
}
}
return vars;
}, [scenes, sceneFieldsMap]);
// Group by scene
const sceneGroups = useMemo(() => {
const groups: { sceneId: string; sceneName: string; fields: typeof allEditableSlots }[] = [];
const seen = new Set<string>();
for (const slot of allEditableSlots) {
if (!seen.has(slot.sceneId)) {
seen.add(slot.sceneId);
groups.push({
sceneId: slot.sceneId,
sceneName: slot.sceneName,
fields: allEditableSlots.filter(s => s.sceneId === slot.sceneId),
});
}
}
return groups;
}, [allEditableSlots]);
const isMultiScene = sceneGroups.length > 1;
return (
<div className="w-[360px] shrink-0 flex flex-col border-r border-neutral-800/60 bg-neutral-950/95 backdrop-blur-sm">
{/* Header */}
<div className="px-4 py-3 border-b border-neutral-800/30 bg-gradient-to-r from-emerald-500/5 to-teal-500/5 shrink-0">
<div className="flex items-center gap-2">
<FlaskConical size={13} className="text-emerald-400" />
<h2 className="text-xs font-bold text-white">Datos de prueba</h2>
<span className="text-[9px] text-emerald-400 bg-emerald-500/10 px-2 py-0.5 rounded-full font-medium">
{allEditableSlots.length} campo{allEditableSlots.length !== 1 ? 's' : ''}
</span>
</div>
<p className="text-[10px] text-neutral-500 mt-1">
Llena los campos para ver cómo se vería tu plantilla con datos reales.
</p>
</div>
{/* Scrollable fields */}
<div className="flex-1 overflow-y-auto custom-scrollbar px-4 py-4 space-y-4">
{allEditableSlots.length === 0 ? (
<div className="text-center py-8">
<FlaskConical size={24} className="text-neutral-700 mx-auto mb-2" />
<p className="text-xs text-neutral-500">No hay campos editables para probar.</p>
</div>
) : isMultiScene ? (
sceneGroups.map(group => (
<div key={group.sceneId} className="space-y-3">
<button
onClick={() => setActiveSceneId(group.sceneId)}
title={`Ir a escena: ${group.sceneName}`}
className={`w-full flex items-center gap-2 px-3 py-2 rounded-lg border text-left transition-all ${
activeSceneId === group.sceneId
? 'border-emerald-500/30 bg-emerald-500/5'
: 'border-neutral-800/50 bg-neutral-900/30 hover:border-neutral-700'
}`}
>
<div className={`w-2 h-2 rounded-full shrink-0 ${
activeSceneId === group.sceneId ? 'bg-emerald-500' : 'bg-neutral-600'
}`} />
<span className="text-[11px] font-semibold text-white flex-1">{group.sceneName}</span>
<span className="text-[9px] text-neutral-500">{group.fields.length} campo{group.fields.length !== 1 ? 's' : ''}</span>
</button>
{group.fields.map(({ field }) => (
<TemplateFieldInput
key={field.id}
field={field}
value={testFieldData[field.id] || ''}
onChange={(v) => setTestFieldData(prev => ({ ...prev, [field.id]: v }))}
designMD={designMD}
mediaFit={testMediaFits[field.id]}
onMediaFitChange={(fit) => setTestMediaFits(prev => ({ ...prev, [field.id]: fit }))}
containBgColor={testContainBgColors[field.id] ?? null}
onContainBgColorChange={(color) => setTestContainBgColors(prev => ({ ...prev, [field.id]: color }))}
/>
))}
</div>
))
) : (
allEditableSlots.map(({ field }) => (
<TemplateFieldInput
key={field.id}
field={field}
value={testFieldData[field.id] || ''}
onChange={(v) => setTestFieldData(prev => ({ ...prev, [field.id]: v }))}
designMD={designMD}
mediaFit={testMediaFits[field.id]}
onMediaFitChange={(fit) => setTestMediaFits(prev => ({ ...prev, [field.id]: fit }))}
containBgColor={testContainBgColors[field.id] ?? null}
onContainBgColorChange={(color) => setTestContainBgColors(prev => ({ ...prev, [field.id]: color }))}
/>
))
)}
{/* Brand variables (read-only) */}
{brandVars.length > 0 && (
<div className="pt-4 border-t border-neutral-800/50">
<p className="text-[9px] text-violet-400 uppercase tracking-wider font-semibold mb-3 flex items-center gap-1">
<Zap size={8} /> Auto-completados desde {company.name}
</p>
<div className="space-y-2">
{brandVars.map(field => (
<div
key={field.id}
className="flex items-center gap-3 px-3 py-2.5 bg-violet-500/5 border border-violet-500/15 rounded-lg"
>
<Zap size={10} className="text-violet-400 shrink-0" />
<div className="flex-1 min-w-0">
<span className="text-[10px] text-violet-300 font-medium">{field.label}</span>
<span className="text-[9px] text-violet-400/50 block truncate">
{resolveBrandTestValue(field, company, designMD) || '(no configurado)'}
</span>
</div>
<span className="text-[7px] text-violet-400 bg-violet-500/10 px-1.5 py-0.5 rounded font-bold shrink-0">
auto
</span>
</div>
))}
</div>
</div>
)}
</div>
</div>
);
};