1/** 2 * Cero Studio Blog Engine (SPA) 3 * Logic for rendering the blog from JSON for Cloudflare Pages. 4 */ 5 6const BLOG_DATA_URL = '/api/posts'; 7const PER_PAGE = 9; 8const INITIAL_LOAD = 50; // carga todos los posts actuales de golpe 9 10let currentPage = 1; 11let hasMore = false; 12let isLoadingMore = false; 13 14async function initBlog() { 15 const root = document.getElementById('blog-root'); 16 17 // ââ SSR fast path: individual post âââââââââââââââââââââââââââââââââââââââ 18 // If the server already rendered this post (data-ssr-slug attribute is set 19 // by functions/blog/[slug].js), skip the initial fetch+render to avoid the 20 // "Cargando artÃculo..." flash and double work. We still warm up the post 21 // list cache in the background so clicking "Volver al blog" feels instant. 22 if (root && root.getAttribute('data-ssr-slug')) { 23 fetch(`${BLOG_DATA_URL}?page=1&per=${INITIAL_LOAD}`) 24 .then(r => r.ok ? r.json() : []) 25 .then(arr => { window._blogPosts = Array.isArray(arr) ? arr : []; }) 26 .catch(() => { window._blogPosts = []; }); 27 window.onpopstate = handleRoute; 28 return; 29 } 30 31 // ââ SSR fast path: blog list âââââââââââââââââââââââââââââââââââââââââââââ 32 // If functions/blog/index.js already rendered the post grid, skip the 33 // initial fetch+render. Warm up the cache in the background so client-side 34 // pagination ("Ver más artÃculos") and SPA navigation back to the list 35 // still work without a roundtrip. 36 if (root && root.getAttribute('data-ssr-list') === 'true') { 37 fetch(`${BLOG_DATA_URL}?page=1&per=${INITIAL_LOAD}`) 38 .then(r => r.ok ? r.json() : []) 39 .then(arr => { 40 const posts = Array.isArray(arr) ? arr : []; 41 window._blogPosts = posts; 42 hasMore = posts.length === INITIAL_LOAD; 43 currentPage = 1; 44 }) 45 .catch(() => { window._blogPosts = []; }); 46 window.onpopstate = handleRoute; 47 return; 48 } 49 50 // ââ Normal path: list view (or SPA-navigated post views) âââââââââââââââââ 51 try { 52 const response = await fetch(`${BLOG_DATA_URL}?page=1&per=${INITIAL_LOAD}`); 53 if (!response.ok) { 54 throw new Error('API responded with status ' + response.status); 55 } 56 const firstBatch = await response.json(); 57 58 // Defensive: the list endpoint should always return an array. If the API 59 // returned an error object (or anything else), fall back to an empty list 60 // instead of letting `.map()` blow up later. 61 const posts = Array.isArray(firstBatch) ? firstBatch : []; 62 63 // hasMore solo es true si llegamos al lÃmite inicial (50+) 64 hasMore = posts.length === INITIAL_LOAD; 65 66 // Seed global posts array for list view 67 window._blogPosts = posts; 68 currentPage = 1; 69 70 // Simple router based on pathname 71 handleRoute(); 72 73 // Listen for back/forward navigation 74 window.onpopstate = handleRoute; 75 76 } catch (error) { 77 console.error('Error loading blog data:', error); 78 if (root) { 79 root.innerHTML = `<div class="section-inner" style="padding: 100px 0; text-align: center;">Error al cargar el blog.</div>`; 80 } 81 } 82} 83 84function handleRoute() { 85 const path = window.location.pathname; 86 const segments = path.split('/').filter(s => s !== ''); 87 88 // /blog/ or /blog/index.html 89 if (segments.length === 1 && segments[0] === 'blog') { 90 renderList(); 91 } 92 // /blog/some-slug 93 else if (segments.length === 2 && segments[0] === 'blog') { 94 const slug = segments[1]; 95 renderPost(slug); 96 } 97 // Default to list 98 else { 99 renderList(); 100 } 101} 102 103function navigate(e, path) { 104 if (e) e.preventDefault(); 105 window.history.pushState({}, '', path); 106 handleRoute(); 107 window.scrollTo(0, 0); 108} 109 110/* Cards: thumbnail derivado (480px, ~19KB) en vez del hero completo. 111 Solo media propia â todo el bucket tiene thumbs (backfill 6 jul 2026). */ 112function cardThumbUrl(u) { 113 return u && u.startsWith('https://media.cerostudio.ai/') ? u + '.thumb.jpg' : u; 114} 115 116function postCardHtml(post) { 117 const thumbHtml = post.featured_image 118 ? '<div class="post-card-thumb"><img src="' + cardThumbUrl(post.featured_image) + '" alt="' + post.t
118itle + '" loading="lazy"></div>' 119 : ''; 120 return '<article class="post-card' + (post.featured_image ? ' has-thumb' : '') + '" data-route="/blog/' + post.slug + '" style="cursor: pointer;">' 121 + thumbHtml 122 + '<div class="post-card-body">' 123 + '<div class="post-card-cat">' + (post.category || 'General') + '</div>' 124 + '<h2 class="post-card-title">' + post.title + '</h2>' 125 + '<p class="post-card-excerpt">' + (post.excerpt || '') + '</p>' 126 + '<div class="post-card-meta">' + formatDate(post.published_at) + '</div>' 127 + '</div>' 128 + '</article>'; 129} 130 131async function renderList() { 132 const root = document.getElementById('blog-root'); 133 let posts = Array.isArray(window._blogPosts) ? window._blogPosts : null; 134 135 // Lazy-fetch the list if it hasn't been loaded yet â happens when the user 136 // arrives via an SSR'd post page and then clicks "Volver al blog" before 137 // the background warm-up completes. 138 if (posts === null) { 139 root.innerHTML = `<div class="section-inner" style="padding: 100px 0; text-align: center;">Cargando blog...</div>`; 140 try { 141 const r = await fetch(`${BLOG_DATA_URL}?page=1&per=${INITIAL_LOAD}`); 142 const data = r.ok ? await r.json() : []; 143 posts = Array.isArray(data) ? data : []; 144 window._blogPosts = posts; 145 hasMore = posts.length === INITIAL_LOAD; 146 } catch { 147 posts = []; 148 } 149 } 150 151 const cardsHtml = posts.length 152 ? posts.map(postCardHtml).join('') 153 : '<p style="grid-column: 1 / -1; text-align: center; padding: 60px 0; color: rgba(255,255,255,.6);">Próximamente nuevos artÃculos.</p>'; 154 155 root.innerHTML = ` 156 <header class="blog-hero"> 157 <div class="section-inner"> 158 <div class="eyebrow">Blog · Para dueños de negocio</div> 159 <h1 class="section-title">Lo que nadie te explica <br class="pc-only">antes de invertir en tu sitio</h1> 160 <p class="blog-hero-sub">Precios reales, comparativas sin comisión y lo que sà mueve ventas en México. Escrito por Carlos Luque, sin tecnicismos.</p> 161 <a class="blog-hero-res" href="/recursos/5-errores-web/"><span class="code">REC-01 · GuÃa gratis</span><span>5 errores que le cuestan a tu web ahora mismo â PDF, sin registro</span><span class="arrow">â</span></a> 162 </div> 163 </header> 164 165 <div class="section-inner"> 166 <div class="blog-grid" id="blog-grid"> 167 ${cardsHtml} 168 </div> 169 <div class="load-more-wrap" id="load-more-wrap" style="${hasMore ? '' : 'display:none'}"> 170 <button class="load-more-btn" id="load-more-btn" data-action="load-more">Ver más artÃculos</button> 171 </div> 172 </div> 173 `; 174 175 document.title = 'Blog · Precios reales y guÃas para vender más en lÃnea | Cero Studio'; 176} 177 178async function loadMore() { 179 if (isLoadingMore) return; 180 isLoadingMore = true; 181 182 const btn = document.getElementById('load-more-btn'); 183 if (btn) btn.textContent = 'Cargando...'; 184 185 try { 186 const nextPage = currentPage + 1; 187 const response = await fetch(`${BLOG_DATA_URL}?page=${nextPage}&per=${INITIAL_LOAD}`); 188 if (!response.ok) throw new Error('API responded with status ' + response.status); 189 const raw = await response.json(); 190 const newPosts = Array.isArray(raw) ? raw : []; 191 192 currentPage = nextPage; 193 hasMore = newPosts.length === INITIAL_LOAD; 194 window._blogPosts = (window._blogPosts || []).concat(newPosts); 195 196 const grid = document.getElementById('blog-grid'); 197 if (grid) { 198 newPosts.forEach(post => { 199 grid.insertAdjacentHTML('beforeend', postCardHtml(post)); 200 }); 201 } 202 203 const wrap = document.getElementById('load-more-wrap'); 204 if (wrap) wrap.style.display = hasMore ? '' : 'none'; 205 206 } catch (e) { 207 console.error('Error cargando más artÃculos:', e); 208 if (btn) btn.textContent = 'Error â intentar de nuevo'; 209 } finally { 210 isLoadingMore = false; 211 const btn2 = document.getElementById('load-more-btn'); 212 if (btn2 && hasMore) btn2.textContent = 'Ver más artÃculos'; 213 } 214} 215 216// CTA final por categorÃa â mismo markup y mapa que functions/blog/[slug].js (SSR). 217// Primario = siguiente paso natural del tema; secundario = ficha; WhatsApp siempre. 218const CTA_BY_CAT = { 219 'Diseño Web': { t: '¿Tu sitio se ve bien <em>pero no vende?</em>', p: 'Revisamos tu sitio actual gratis y en 48 horas te decimos qué lo está frenando. Y si lo que necesitas es uno nuevo, tienes propuesta en menos de 24 horas.', b1: ['AuditorÃa gratis de mi sitio', '/auditoria-gratis/'], b2: ['Ver planes de desarrollo web', '/servicios/desarrollo-web/'] }, 220 'SEO': { t: '¿Google y la IA <em>te encuentran?</em>', p: 'Te decimos gratis, en 48 horas, si tu sitio aparece donde tus clientes buscan â y qué le falta para rankear en Google y salir en ChatGPT.', b1: ['AuditorÃa SEO + IA gratis', '/auditoria-gratis/'], b2: ['Ver servicio SEO + AI Search', '/servicios/seo/'] },
221 'eCommerce': { t: '¿Tu tienda recibe visitas <em>y no vende?</em>', p: 'Somos Agencia Tiendanube Partner certificada. Te decimos si te conviene Tiendanube o una tienda a la medida â y qué arreglar hoy en la que ya tienes.', b1: ['Cotizar mi tienda', '/servicios/tiendas-ecommerce/'], b2: ['AuditorÃa gratis de mi tienda', '/auditoria-gratis/'] }, 222 'Estrategia Digital': { t: 'Antes de invertir, <em>un segundo par de ojos.</em>', p: 'Primera consulta gratis con Carlos: te decimos qué sà vale la pena hacer en tu caso y qué no. Sin compromiso.', b1: ['Agendar consulta gratis', 'https://calendar.app.google/Mk3sTdFWsaaaUNnj6'], b2: ['Ver consultorÃa digital', '/servicios/consultoria-digital/'] }, 223 'Casos de Ãxito': { t: '¿Quieres resultados asà <em>en tu negocio?</em>', p: 'Cuéntanos qué vendes y a quién. Recibes una propuesta clara en menos de 24 horas.', b1: ['Cotizar mi proyecto', '/#contacto'], b2: ['Ver más casos de éxito', '/casos-de-exito/'] } 224}; 225CTA_BY_CAT['Inteligencia Artificial'] = CTA_BY_CAT['SEO']; 226CTA_BY_CAT['Marketing Digital'] = CTA_BY_CAT['Estrategia Digital']; 227const CTA_DEFAULT = { t: '¿Tu sitio se ve bien <em>pero no vende?</em>', p: 'Diseñamos sitios que convierten visitas en clientes. Cuéntanos tu proyecto y recibe una propuesta clara en menos de 24 horas.', b1: ['Cotizar mi proyecto', '/#contacto'], b2: ['AuditorÃa gratis de mi sitio', '/auditoria-gratis/'] }; 228 229function ctaEndFor(cat) { 230 const c = CTA_BY_CAT[cat] || CTA_DEFAULT; 231 return `<aside class="article-cta" data-label="Siguiente paso"><h2 class="article-cta-title">${c.t}</h2><p class="article-cta-text">${c.p}</p><div class="article-cta-row"><a href="${c.b1[1]}" class="article-cta-btn">${c.b1[0]} â</a><a href="${c.b2[1]}" class="article-cta-wa">${c.b2[0]}</a><a href="https://wa.me/525531007101?text=Hola%2C%20le%C3%AD%20un%20art%C3%ADculo%20de%20su%20blog%20y%20quiero%20cotizar%20mi%20proyecto." class="article-cta-wa" target="_blank" rel="noopener">WhatsApp directo</a></div></aside>`; 232} 233 234// CTA inline: transicional (auditorÃa gratis), nunca cotización a media lectura 235const ARTICLE_CTA_MID = '<div class="article-cta-inline"><span>¿No sabes si esto le pasa a tu sitio?</span> <a href="/auditoria-gratis/">PÃdenos la auditorÃa exprés gratis â 5 hallazgos en 48 horas â</a></div>'; 236 237// Caja de autor (BLOG-03) â mismo markup que functions/blog/[slug].js. 238// Retrato Color; el B/N lo pone blog.css (filter: grayscale) â sin acento de esquina. 239const ARTICLE_AUTHOR = '<aside class="article-author"><img src="/images/Carlos_Luque_2026-Color.webp" alt="Carlos Luque â Fundador, Cero Studio" width="88" height="120" loading="lazy"><div><p class="article-author-name">Carlos Luque</p><p class="article-author-bio">Fundador de Cero Studio. Más de 25 años construyendo presencia digital para negocios en México. Escribe esto para que decidas con datos, no con vendedores.</p><a href="/nosotros/" class="article-author-more">Conoce a Carlos â</a></div></aside>'; 240 241// Inserta el CTA inline a la mitad del artÃculo (solo si hay â¥8 párrafos) 242function withMidCta(content) { 243 const parts = (content || '').split('</p>'); 244 if (parts.length < 9) return content; 245 const mid = Math.ceil((parts.length - 1) / 2); 246 return parts.slice(0, mid).join('</p>') + '</p>' + ARTICLE_CTA_MID + parts.slice(mid).join('</p>'); 247} 248 249async function renderPost(slug) { 250 const root = document.getElementById('blog-root'); 251 252 // Fetch specific post from API 253 root.innerHTML = `<div class="section-inner" style="padding: 100px 0; text-align: center;">Cargando artÃculo...</div>`; 254
255 try { 256 const response = await fetch(`${BLOG_DATA_URL}?slug=${slug}`); 257 if (!response.ok) throw new Error('Not found'); 258 const post = await response.json(); 259 260 const heroImg = post.featured_image 261 ? '<div class="article-featured-image"><img src="' + post.featured_image + '" alt="' + (post.featured_image_alt || post.title) + '"></div>' 262 : ''; 263 264 root.innerHTML = ` 265 <article class="article-container"> 266 <a href="/blog/" data-route="/blog/" class="back-link"> 267 â Volver al blog 268 </a> 269 270 <header class="article-header"> 271 <div class="post-card-cat" style="margin-bottom: 24px;">${post.category || 'General'}</div> 272 <h1 class="article-title">${post.title}</h1> 273 <div class="article-meta"> 274 Por <a href="/nosotros/" class="article-author-link">Carlos Luque</a> · Fundador de Cero Studio · ${formatDate(post.published_at)} 275 </div> 276 ${heroImg} 277 </header> 278 279 <div class="article-content"> 280 ${withMidCta(post.content)} 281 </div> 282${ARTICLE_AUTHOR} 283${ctaEndFor(post.category)} 284 </article> 285 `; 286 287 document.title = `${post.title} â Blog Cero Studio`; 288 } catch (error) { 289 root.innerHTML = `<div class="section-inner" style="padding: 100px 0; text-align: center;">ArtÃculo no encontrado. <br><br> <a href="/blog/" data-route="/blog/" class="back-link">Volver al blog</a></div>`; 290 } 291} 292 293function formatDate(dateStr) { 294 if (!dateStr) return ''; 295 /* Dos formatos conviven en D1: sqlite "YYYY-MM-DD HH:MM:SS" (posts viejos, 296 necesita el truco de las diagonales para Safari) e ISO con T/Z (posts 297 programados). El replace de guiones ROMPE el ISO â Invalid Date. */ 298 const d = dateStr.includes('T') 299 ? new Date(dateStr) 300 : new Date(dateStr.replace(/-/g, '/')); 301 if (isNaN(d)) return ''; 302 return d.toLocaleDateString('es-MX', { 303 day: 'numeric', 304 month: 'long', 305 year: 'numeric' 306 }); 307} 308 309/* Delegación CSP-safe: los templates usan data-route / data-action 310 en vez de handlers inline (bloqueados por CSP sin 'unsafe-inline'). */ 311document.addEventListener('click', function (e) { 312 if (!e.target.closest) return; 313 if (e.target.closest('[data-action="load-more"]')) { loadMore(); return; } 314 var r = e.target.closest('[data-route]'); 315 if (r) navigate(e, r.getAttribute('data-route')); 316}); 317window.navigate = navigate; 318window.loadMore = loadMore; 319 320// --- Navigation Logic --- 321function toggleNav() { 322 const nav = document.getElementById('mobileNav'); 323 const ham = document.querySelector('.nav-ham'); 324 nav.classList.toggle('open'); 325 ham.classList.toggle('open'); 326} 327 328function closeNav() { 329 const nav = document.getElementById('mobileNav'); 330 const ham = document.querySelector('.nav-ham'); 331 nav.classList.remove('open'); 332 ham.classList.remove('open'); 333} 334 335// Scroll effects 336window.addEventListener('scroll', () => { 337 const nav = document.getElementById('navbar'); 338 if (window.scrollY > 50) { 339 nav.classList.add('scrolled'); 340 } else { 341 nav.classList.remove('scrolled'); 342 } 343}); 344 345// Global exposure 346window.toggleNav = toggleNav; 347window.closeNav = closeNav; 348 349// Start the engine 350document.addEventListener('DOMContentLoaded', initBlog);
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.