https://mi-landing.netlify.app
Suelta el bloque aquí
Se añadirá al final de la página
Canvas vacío
Arrastra bloques del panel izquierdo o carga una plantilla para empezar a editar.
🚀 Publicar en Netlify
Tu landing quedará disponible en internet con tu dominio personalizado. Puedes editarlo después y volver a publicar.
→ mi-landing.netlify.app
📐 Plantillas base
Selecciona una plantilla para empezar. Podrás editar todo el contenido.
🦅
Landing Elvis
Plantilla de prospección y venta. 3,118 líneas. Ya adaptada a Team Inquebrantables.
🛍️
Venta Directa
Producto + precio tachado + beneficios + formulario + CTA WhatsApp.
👥
Captación de Equipo
Hero de prospección + beneficios red + testimonios + CTA negocio.
🎯
Webinar / Evento
Próximamente — registro a eventos y transmisiones en vivo.
D
Daniel
axelfarmasi@gmail.com
Admin · TeamInquebrantables
<\/body><\/html>`,
'captacion': `
{{hero-prospeccion}}
{{beneficios}}
{{testimonios}}
{{countdown}}
{{formulario-leads}}
{{cta-final}}
<\/body><\/html>`
};
// Resuelve los placeholders {{block-name}} en una plantilla
function resolveTemplate(tpl) {
return tpl.replace(/\{\{([^}]+)\}\}/g, (_, key) => {
return BLOCKS_HTML[key.trim()] || '';
});
}
// ── Inicializar GrapesJS ──────────────────────────────
function initGrapesJS() {
// Configuración mínima pero completa
editor = grapesjs.init({
// GrapesJS controla el iframe; nosotros le pasamos el contenedor
container: '#gjs',
storageManager: false,
assetManager: { assets: [] },
fromElement: true,
// Sin panel de bloque nativo (usamos el nuestro del sidebar)
blockManager: { appendTo: '#gjs-blocks-hidden' },
// Sin panel de estilo nativo
styleManager: { appendTo: '#gjs-styles-hidden' },
// Sin layers nativo
layerManager: { appendTo: '#gjs-layers-hidden' },
// Sin panel de traits nativo
traitManager: { appendTo: '#gjs-traits-hidden' },
panels: { defaults: [] },
deviceManager: {
devices: [
{
name: 'Desktop',
width: '', // full width
}
]
},
// Canvas: inyectamos nuestro CSS + Google Fonts dentro del iframe
canvas: {
styles: [
'https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@400;500;600;700;800&display=swap',
'https://cdnjs.cloudflare.com/ajax/libs/bootstrap/5.0.2/css/bootstrap.min.css',
'https://cdnjs.cloudflare.com/ajax/libs/normalize/8.0.1/normalize.min.css'
],
scripts: []
},
// Toolbar del componente seleccionado
showToolbar: true,
// Inline editing — doble clic en texto para editar
richTextEditor: {
actions: ['bold', 'italic', 'underline', 'link', 'strikethrough']
},
// Permitir selección y edición de cualquier elemento
allowScripts: 1,
protectedCss: ''
});
editor.on('load', () => {
editorReady = true;
console.log('Editor listo');
});
// Inyectar CSS de Farmasi + marcar editor listo
editor.on('load', () => {
try {
const head = editor.Canvas.getDocument().head;
// Inyectar Google Fonts + CSS base
head.insertAdjacentHTML('beforeend',
'
' +
'
' +
''
);
} catch(_) {}
editorReady = true;
document.getElementById('canvas-empty').classList.add('hidden');
refreshLayers();
injectDragIntoGjsIframe();
showToast('Editor listo 🦅 — arrastra bloques o carga una plantilla', 'success');
});
// Cuando el usuario selecciona un componente → actualizar panel de propiedades
editor.on('component:selected', (component) => {
if (!component) return;
onComponentSelected(component);
});
// Cuando deselecciona
editor.on('component:deselected', () => {
document.getElementById('panel-no-selection').classList.remove('hidden');
document.getElementById('panel-props-active').classList.add('hidden');
});
// Cuando cambia el HTML → marcar como "no guardado"
editor.on('change', () => {
setSaveStatus('unsaved');
});
// Registrar los 8 bloques Farmasi en el block manager de GrapesJS
registerFarmasiBlocks();
return editor;
}
// ── Registrar bloques en GrapesJS ─────────────────────
function registerFarmasiBlocks() {
const bm = editor.BlockManager;
const blockDefs = [
{ id: 'hero-venta', label: 'Hero Venta', category: 'Hero' },
{ id: 'hero-prospeccion', label: 'Hero Prospección', category: 'Hero' },
{ id: 'beneficios', label: 'Beneficios', category: 'Contenido' },
{ id: 'testimonios', label: 'Testimonios', category: 'Contenido' },
{ id: 'producto-precio', label: 'Producto + Precio',category: 'Farmasi MX' },
{ id: 'countdown', label: 'Countdown', category: 'Farmasi MX' },
{ id: 'formulario-leads', label: 'Captura Leads', category: 'Conversión' },
{ id: 'cta-final', label: 'CTA WhatsApp', category: 'Conversión' },
];
blockDefs.forEach(({ id, label, category }) => {
bm.add(id, {
label,
category,
content: BLOCKS_HTML[id],
attributes: { class: 'gjs-block-section' }
});
});
}
// ── Cargar plantilla en el editor ─────────────────────
function loadTemplate(tplKey) {
if (!editorReady) {
showToast('Cargando editor… reintentando ⏳', 'info');
let attempts = 0;
const retry = setInterval(() => {
attempts++;
if (editorReady) { clearInterval(retry); loadTemplate(tplKey); }
else if (attempts > 16) { clearInterval(retry); showToast('El editor tardó demasiado. Recarga la página.', 'error'); }
}, 500);
return;
}
let html;
if (tplKey === 'elvis') {
// Elvis se carga como iframe separado, no por GrapesJS
// Se abre en preview externo
loadElvisTemplate();
return;
}
if (!TEMPLATES[tplKey]) {
showToast('Plantilla no encontrada', 'error');
return;
}
html = resolveTemplate(TEMPLATES[tplKey]);
editor.setComponents(html);
editor.setStyle(FARMASI_CSS);
document.getElementById('canvas-empty').classList.add('hidden');
refreshLayers();
setSaveStatus('unsaved');
showToast('Plantilla cargada ✅', 'success');
}
// Elvis: carga directo en el iframe (sin GrapesJS, solo lectura editable)
function loadElvisTemplate() {
showToast('Cargando plantilla Elvis…', 'info');
// El archivo está en el proyecto; lo cargamos con fetch relativo o lo pedimos al usuario
// Por ahora mostramos instrucción clara
showToast('Carga el archivo Elvis_Inquebrantable.html desde el botón "Cargar" del toolbar', 'info');
}
// ── Insertar bloque desde sidebar (drag fallback = click) ──
function insertBlockById(blockId) {
if (!editorReady) {
let att = 0;
const r = setInterval(() => {
att++;
if (editorReady) { clearInterval(r); insertBlockById(blockId); }
else if (att > 16) { clearInterval(r); showToast('Editor no listo aún. Recarga la página.', 'error'); }
}, 500);
return;
}
const html = BLOCKS_HTML[blockId];
if (!html) return;
// Append al final del body del canvas
const wrapper = editor.getWrapper();
editor.addComponents(html);
document.getElementById('canvas-empty').classList.add('hidden');
refreshLayers();
setSaveStatus('unsaved');
showToast('Bloque añadido ✅', 'success');
}
// SECCIÓN 2C — PANEL PROPIEDADES + ACCIONES + CARGA/DESCARGA
// ── Estado del componente seleccionado ───────────────
let selectedComponent = null;
// ── Cuando GrapesJS selecciona un componente ─────────
function onComponentSelected(component) {
selectedComponent = component;
// Mostrar panel de propiedades
document.getElementById('panel-no-selection').classList.add('hidden');
document.getElementById('panel-props-active').classList.remove('hidden');
// Tag y nombre del elemento
const tag = component.get('tagName') || 'div';
document.getElementById('sel-tag').textContent = tag.toUpperCase();
// Nombre descriptivo: usa el ID, clase principal o tipo
const classes = component.getClasses();
const name = classes.length > 0
? classes[0].replace('bloque-', '').replace(/-/g, ' ')
: tag;
document.getElementById('sel-name').textContent = name;
// Leer estilos actuales y reflejar en el panel
syncPanelFromComponent(component);
}
// ── Leer estilos del componente → actualizar controles ──
function syncPanelFromComponent(component) {
const style = component.getStyle();
// Color de texto
const color = style['color'] || '#FFFFFF';
document.getElementById('prop-color').value = hexNormalize(color);
document.getElementById('swatch-color').style.background = color;
document.getElementById('prop-color-hex').value = hexNormalize(color).toUpperCase();
// Color de fondo
const bg = style['background-color'] || style['background'] || '#080808';
const bgHex = bg.startsWith('#') ? bg : '#080808';
document.getElementById('prop-bg-color').value = bgHex;
document.getElementById('swatch-bg').style.background = bgHex;
document.getElementById('prop-bg-hex').value = bgHex.toUpperCase();
// Opacidad
const opacity = parseFloat(style['opacity'] || 1) * 100;
document.getElementById('prop-opacity').value = opacity;
document.getElementById('val-opacity').textContent = Math.round(opacity) + '%';
// Padding top / bottom
const pt = parseInt(style['padding-top'] || style['padding'] || 80);
const pb = parseInt(style['padding-bottom'] || style['padding'] || 80);
document.getElementById('prop-pt').value = isNaN(pt) ? 80 : pt;
document.getElementById('val-pt').textContent = (isNaN(pt) ? 80 : pt) + 'px';
document.getElementById('prop-pb').value = isNaN(pb) ? 80 : pb;
document.getElementById('val-pb').textContent = (isNaN(pb) ? 80 : pb) + 'px';
// Font size
const fs = parseInt(style['font-size'] || 16);
document.getElementById('prop-font-size').value = isNaN(fs) ? 16 : fs;
// Font weight
const fw = style['font-weight'] || '400';
const fwSel = document.getElementById('prop-font-weight');
if (fwSel) fwSel.value = fw;
// Imagen src (si el componente es img)
const src = component.get('src') || component.getAttributes()['src'] || '';
document.getElementById('prop-img-url').value = src;
// Alineación
const align = style['text-align'] || 'left';
document.querySelectorAll('.prop-align-btn').forEach(btn => {
btn.classList.toggle('active', btn.dataset.align === align);
});
}
// ── Aplicar cambio de propiedad al componente seleccionado ──
function applyStyleToSelected(prop, value) {
if (!selectedComponent || !editor) return;
const styleMap = {
'color': 'color',
'backgroundColor': 'background-color',
'opacity': 'opacity',
'paddingTop': 'padding-top',
'paddingBottom': 'padding-bottom',
'fontSize': 'font-size',
'fontWeight': 'font-weight',
'fontFamily': 'font-family',
'textAlign': 'text-align'
};
const cssProp = styleMap[prop] || prop;
selectedComponent.addStyle({ [cssProp]: value });
setSaveStatus('unsaved');
}
// ── Aplicar src de imagen ─────────────────────────────
function applyImageSrc(src) {
if (!selectedComponent || !editor) return;
// Si el componente seleccionado es una imagen
if (selectedComponent.get('tagName') === 'img') {
selectedComponent.set('src', src);
selectedComponent.addAttributes({ src });
} else {
// Buscar la primera img hija
const img = selectedComponent.find('img')[0];
if (img) {
img.set('src', src);
img.addAttributes({ src });
}
}
setSaveStatus('unsaved');
}
// ── Escuchar eventos del panel de propiedades ─────────
function bindPanelEvents() {
// Escuchar el evento personalizado emitido por los controles del Bloque 1
document.addEventListener('propiedadCambiada', (e) => {
const { tipo, valor } = e.detail;
if (tipo === 'src') {
applyImageSrc(valor);
} else {
applyStyleToSelected(tipo, valor);
}
});
// Font family
const fontSel = document.getElementById('prop-font');
if (fontSel) fontSel.addEventListener('change', function() {
applyStyleToSelected('fontFamily', this.value);
});
// Font size input libre
const fontSizeInput = document.getElementById('prop-font-size');
if (fontSizeInput) fontSizeInput.addEventListener('change', function() {
const val = parseInt(this.value);
if (!isNaN(val) && val > 0) applyStyleToSelected('fontSize', val + 'px');
});
// Image URL input
const imgUrl = document.getElementById('prop-img-url');
if (imgUrl) imgUrl.addEventListener('change', function() {
applyImageSrc(this.value.trim());
});
// Image file upload desde panel
const imgFile = document.getElementById('prop-img-file');
if (imgFile) imgFile.addEventListener('change', function(e) {
const file = e.target.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (ev) => {
const dataUrl = ev.target.result;
document.getElementById('prop-img-url').value = dataUrl.substring(0, 60) + '…';
applyImageSrc(dataUrl);
addAssetToPanel(dataUrl, file.name);
};
reader.readAsDataURL(file);
});
}
// ── Acciones sobre bloques ────────────────────────────
function duplicateBlock() {
if (!selectedComponent) { showToast('Selecciona un bloque primero', 'info'); return; }
selectedComponent.clone();
refreshLayers();
setSaveStatus('unsaved');
showToast('Bloque duplicado ✅', 'success');
}
function moveBlockUp() {
if (!selectedComponent) { showToast('Selecciona un bloque primero', 'info'); return; }
const parent = selectedComponent.parent();
if (!parent) return;
const idx = parent.components().indexOf(selectedComponent);
if (idx <= 0) { showToast('Ya está en la primera posición', 'info'); return; }
selectedComponent.move(parent, { at: idx - 1 });
refreshLayers();
setSaveStatus('unsaved');
}
function moveBlockDown() {
if (!selectedComponent) { showToast('Selecciona un bloque primero', 'info'); return; }
const parent = selectedComponent.parent();
if (!parent) return;
const total = parent.components().length;
const idx = parent.components().indexOf(selectedComponent);
if (idx >= total - 1) { showToast('Ya está en la última posición', 'info'); return; }
selectedComponent.move(parent, { at: idx + 1 });
refreshLayers();
setSaveStatus('unsaved');
}
function deleteBlock() {
if (!selectedComponent) { showToast('Selecciona un bloque primero', 'info'); return; }
if (!confirm('¿Eliminar este bloque? No se puede deshacer.')) return;
selectedComponent.remove();
selectedComponent = null;
document.getElementById('panel-no-selection').classList.remove('hidden');
document.getElementById('panel-props-active').classList.add('hidden');
refreshLayers();
setSaveStatus('unsaved');
showToast('Bloque eliminado', 'info');
}
// ── Capas (Layers) ────────────────────────────────────
function refreshLayers() {
if (!editor || !editorReady) return;
const container = document.getElementById('layers-list');
const wrapper = editor.getWrapper();
if (!wrapper) return;
const items = wrapper.components();
if (items.length === 0) {
container.innerHTML = '
Sin bloques aún
';
return;
}
container.innerHTML = '';
items.forEach((comp, i) => {
const tag = comp.get('tagName') || 'div';
const classes = comp.getClasses();
const name = classes.find(c => c.startsWith('bloque-'))
? classes.find(c => c.startsWith('bloque-')).replace('bloque-', '').replace(/-/g, ' ')
: tag + ' ' + (i + 1);
const item = document.createElement('div');
item.className = 'sb-layer-item';
item.innerHTML = `
${name}
${tag.toUpperCase()}
`;
item.addEventListener('click', () => {
editor.select(comp);
// Scroll al componente en el canvas
comp.view.el.scrollIntoView({ behavior: 'smooth', block: 'center' });
});
container.appendChild(item);
});
}
// ── Cargar HTML externo desde archivo ─────────────────
function triggerLoadHTML() {
// El label wrapper activa el input directamente — no necesita JS
const inp = document.getElementById('load-html-input');
if (inp) inp.click();
}
function setupLoadHTMLInput() {
const input = document.getElementById('load-html-input');
input.addEventListener('change', function(e) {
const file = e.target.files[0];
if (!file) return;
if (!file.name.endsWith('.html')) {
showToast('Solo se aceptan archivos .html', 'error');
return;
}
const reader = new FileReader();
reader.onload = (ev) => {
const html = ev.target.result;
loadHTMLIntoEditor(html, file.name);
};
reader.readAsText(file);
// Resetear el input para permitir recargar el mismo archivo
input.value = '';
});
}
function loadHTMLIntoEditor(rawHTML, filename) {
if (!editorReady) {
showToast('El editor aún no está listo', 'error');
return;
}
// Extraer el body
let content = rawHTML;
const bodyMatch = rawHTML.match(/]*>([\s\S]*?)<\/body>/i);
if (bodyMatch) content = bodyMatch[1];
// Extraer TODOS los estilos del archivo (style tags + link tags)
let extractedCSS = '';
const styleMatches = rawHTML.match(/
${body}
// Countdown automático
(function() {
var targets = document.querySelectorAll('[data-cd]');
if (!targets.length) return;
var endTime = localStorage.getItem('cd_end');
if (!endTime) {
endTime = Date.now() + (2 * 24 * 60 * 60 * 1000); // 2 días
localStorage.setItem('cd_end', endTime);
}
function update() {
var diff = Math.max(0, endTime - Date.now());
var d = Math.floor(diff / 86400000);
var h = Math.floor((diff % 86400000) / 3600000);
var m = Math.floor((diff % 3600000) / 60000);
var s = Math.floor((diff % 60000) / 1000);
document.querySelectorAll('[data-cd="days"]').forEach(el => el.textContent = String(d).padStart(2,'0'));
document.querySelectorAll('[data-cd="hours"]').forEach(el => el.textContent = String(h).padStart(2,'0'));
document.querySelectorAll('[data-cd="mins"]').forEach(el => el.textContent = String(m).padStart(2,'0'));
document.querySelectorAll('[data-cd="secs"]').forEach(el => el.textContent = String(s).padStart(2,'0'));
}
update();
setInterval(update, 1000);
})();
<\/script>
`;
}
// ── Vista previa ──────────────────────────────────────
function openPreview() {
if (!editorReady) {
showToast('No hay contenido para previsualizar', 'info');
return;
}
const html = buildFinalHTML();
// Crear un iframe de preview en un modal — evita el COOP bloqueado
let overlay = document.getElementById('preview-overlay');
if (!overlay) {
overlay = document.createElement('div');
overlay.id = 'preview-overlay';
overlay.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.92);z-index:9999;display:flex;flex-direction:column;';
overlay.innerHTML = `
🦅 Preview — Vista previa de la landing
`;
document.body.appendChild(overlay);
}
// Escribir HTML directamente en el iframe — sin blob URL, sin COOP
const iframe = overlay.querySelector('#preview-iframe');
const iframeDoc = iframe.contentDocument || iframe.contentWindow.document;
iframeDoc.open();
iframeDoc.write(html);
iframeDoc.close();
showToast('Preview abierto ✅', 'success');
}
// ── Assets panel ─────────────────────────────────────
function addAssetToPanel(dataUrl, name) {
const grid = document.getElementById('assets-grid');
// Limpiar el placeholder si existe
const placeholder = grid.querySelector('[style*="color:var"]');
if (placeholder) placeholder.remove();
const thumb = document.createElement('div');
thumb.style.cssText = 'background:#141414;border:1px solid var(--border);border-radius:6px;overflow:hidden;cursor:pointer;aspect-ratio:1';
thumb.innerHTML = `
`;
thumb.addEventListener('click', () => {
applyImageSrc(dataUrl);
showToast('Imagen aplicada ✅', 'success');
});
grid.prepend(thumb);
}
function triggerAssetUpload() {
document.getElementById('asset-upload-input').click();
}
function setupAssetUpload() {
const input = document.getElementById('asset-upload-input');
input.addEventListener('change', function(e) {
const file = e.target.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (ev) => {
addAssetToPanel(ev.target.result, file.name);
showToast('Imagen añadida a activos ✅', 'success');
};
reader.readAsDataURL(file);
input.value = '';
});
}
// ── Estado de guardado ────────────────────────────────
function setSaveStatus(state) {
const dot = document.getElementById('save-dot');
const label = document.getElementById('save-label');
if (state === 'saved') {
dot.className = 'tb-save-dot saved';
label.textContent = 'Guardado';
} else if (state === 'saving') {
dot.className = 'tb-save-dot saving';
label.textContent = 'Guardando…';
} else {
dot.className = 'tb-save-dot';
label.textContent = 'Sin guardar';
}
}
// ── Escape HTML helper ────────────────────────────────
function escapeHtml(str) {
return String(str)
.replace(/&/g, '&')
.replace(//g, '>')
.replace(/"/g, '"');
}
// ── Normalizar color hex ──────────────────────────────
function hexNormalize(color) {
if (!color) return '#ffffff';
if (color.startsWith('#')) return color.length === 4
? '#' + color[1]+color[1]+color[2]+color[2]+color[3]+color[3]
: color;
// rgb(r,g,b) → hex
const m = color.match(/rgb\((\d+),\s*(\d+),\s*(\d+)\)/);
if (m) {
return '#' + [m[1],m[2],m[3]]
.map(n => parseInt(n).toString(16).padStart(2,'0'))
.join('');
}
return '#ffffff';
}
// SECCIÓN 2D — DRAG & DROP + COUNTDOWN + INIT PRINCIPAL
// ── Drag & drop del sidebar al canvas ────────────────
function setupDragAndDrop() {
const blockCards = document.querySelectorAll('.sb-block-card');
const dropOverlay = document.getElementById('drop-overlay');
const canvasViewport = document.getElementById('canvas-viewport');
let draggedBlockId = null;
blockCards.forEach(card => {
card.setAttribute('draggable', 'true');
card.addEventListener('dragstart', (e) => {
draggedBlockId = card.dataset.block;
e.dataTransfer.setData('text/plain', draggedBlockId);
e.dataTransfer.effectAllowed = 'copy';
card.classList.add('dragging');
});
card.addEventListener('dragend', () => {
card.classList.remove('dragging');
dropOverlay.classList.remove('show');
});
// Click fallback — funciona siempre, incluso sin drag support
card.addEventListener('click', () => {
const blockId = card.dataset.block;
if (blockId) insertBlockById(blockId);
});
});
// ── Eventos en el viewport del documento principal ──
canvasViewport.addEventListener('dragover', (e) => {
e.preventDefault();
e.dataTransfer.dropEffect = 'copy';
dropOverlay.classList.add('show');
});
canvasViewport.addEventListener('dragleave', (e) => {
if (!canvasViewport.contains(e.relatedTarget)) {
dropOverlay.classList.remove('show');
}
});
canvasViewport.addEventListener('drop', (e) => {
e.preventDefault();
dropOverlay.classList.remove('show');
const blockId = e.dataTransfer.getData('text/plain') || draggedBlockId;
if (blockId) insertBlockById(blockId);
draggedBlockId = null;
});
// El canvas viewport ya tiene dragover/drop registrados arriba.
// La inyección en el iframe de GrapesJS se hace desde editor.on('load')
// via injectDragIntoGjsIframe() — ver initGrapesJS().
}
// ── Inyectar drag en iframe de GrapesJS (llamado desde editor.on load) ──
function injectDragIntoGjsIframe() {
if (!editor) return;
const dropOverlay = document.getElementById('drop-overlay');
try {
const gjsFrame = editor.Canvas.getFrameEl();
if (!gjsFrame) return;
const iframeDoc = gjsFrame.contentDocument || gjsFrame.contentWindow.document;
iframeDoc.addEventListener('dragover', (e) => {
e.preventDefault();
if (dropOverlay) dropOverlay.classList.add('show');
});
iframeDoc.addEventListener('dragleave', () => {
if (dropOverlay) dropOverlay.classList.remove('show');
});
iframeDoc.addEventListener('drop', (e) => {
e.preventDefault();
if (dropOverlay) dropOverlay.classList.remove('show');
const blockId = e.dataTransfer ? e.dataTransfer.getData('text/plain') : '';
if (blockId) insertBlockById(blockId);
});
} catch (_) {}
}
// ── Countdown en el canvas (se activa post-carga) ─────
// El countdown real va en el HTML descargado (buildFinalHTML)
// En el canvas solo actualizamos los spans cada segundo
let countdownInterval = null;
function startCanvasCountdown() {
if (countdownInterval) clearInterval(countdownInterval);
// Usar 2 días a partir de ahora como referencia
const endTime = Date.now() + (2 * 24 * 60 * 60 * 1000);
function updateCanvas() {
if (!editor || !editorReady) return;
const diff = Math.max(0, endTime - Date.now());
const d = Math.floor(diff / 86400000);
const h = Math.floor((diff % 86400000) / 3600000);
const m = Math.floor((diff % 3600000) / 60000);
const s = Math.floor((diff % 60000) / 1000);
const vals = { days: d, hours: h, mins: m, secs: s };
// Acceder al DOM dentro del iframe de GrapesJS
try {
const iframeDoc = editor.Canvas.getDocument();
Object.entries(vals).forEach(([key, val]) => {
iframeDoc.querySelectorAll('[data-cd="' + key + '"]').forEach(el => {
el.textContent = String(val).padStart(2, '0');
});
});
} catch (_) {
// Canvas aún no listo — silenciar
}
}
updateCanvas();
countdownInterval = setInterval(updateCanvas, 1000);
}
// ── Auto-resize del iframe con el contenido ───────────
function setupIframeAutoResize() {
if (!editor) return;
editor.on('component:add', () => resizeIframe());
editor.on('component:remove', () => resizeIframe());
editor.on('load', () => {
resizeIframe();
startCanvasCountdown();
});
}
function resizeIframe() {
if (!editor || !editorReady) return;
try {
const doc = editor.Canvas.getDocument();
const height = Math.max(600, doc.body.scrollHeight + 40);
editor.Canvas.getFrameEl().style.height = height + 'px';
const gjsCanvas = document.getElementById('gjs-canvas');
if (gjsCanvas) gjsCanvas.style.minHeight = height + 'px';
} catch (_) {}
}
// ── Slug: sync avanzada ───────────────────────────────
// Ya definida en Bloque 1 (syncUrlBar), extendemos aquí:
function suggestSlugFromName(name) {
// Elvis Hernández → elvis-hernandez
return name
.toLowerCase()
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '') // remover tildes
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
.substring(0, 40);
}
// Al cambiar el nombre del proyecto, sugerir slug
function bindProjectNameToSlug() {
const nameInput = document.getElementById('project-name-input');
if (!nameInput) return;
nameInput.addEventListener('input', function() {
// Solo sugerir si el slug no ha sido editado manualmente
const slugInput = document.getElementById('pub-slug-input');
if (!slugInput) return;
const suggested = suggestSlugFromName(this.value);
if (suggested) {
slugInput.value = suggested;
syncUrlBar(suggested);
}
});
}
// ── Inicialización principal (DOMContentLoaded) ───────
function initEditor() {
// 1. Cargar GrapesJS desde CDN (ya debe estar en el HTML)
// Verificar que grapesjs está disponible
if (typeof grapesjs === 'undefined') {
console.error('GrapesJS no está cargado. Verifica el CDN.');
showToast('Error: Editor no pudo cargar. Recarga la página.', 'error');
return;
}
// 2+3. GrapesJS container ya existe en el HTML como #gjs-canvas
// Inicializar directamente
initGrapesJS();
// 4. Setup de eventos
setupDragAndDrop();
setupIframeAutoResize();
bindPanelEvents();
setupLoadHTMLInput();
setupAssetUpload();
bindProjectNameToSlug();
// 5. Ocultar containers ocultos de GrapesJS nativo
['#gjs-blocks-hidden','#gjs-styles-hidden','#gjs-layers-hidden','#gjs-traits-hidden'].forEach(sel => {
const el = document.querySelector(sel);
if (el) el.style.display = 'none';
});
// 6. Mostrar toast de bienvenida
// Toast movido a editor.on('load') en initGrapesJS para dispararse cuando realmente esté listo
}
// ── Entry point ───────────────────────────────────────
// Se ejecuta solo cuando el DOM está listo y el usuario está autenticado
// (La autenticación de Firebase del Bloque 3 llama a initEditor() post-login)