PageSourceSearch

https://cerostudio.ai/blog/blog.js?v=20260909a

js cerostudio.ai collected 2026-10-05 11:54:01 UTC 15,993 bytes, 350 lines download raw bytes

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]}&nbsp;→</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&nbsp;→</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&nbsp;→</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.