Ir al contenido

Conectar una sección custom

Guía completa: un módulo reutilizable, una sección que lo usa, y el cableado en el tema. El ejemplo renderiza FAQs, pero la estructura sirve para cualquier tipo.

Un solo archivo con todo lo que se comparte: el fetch cacheado, el filtro por tipo y la regla de render.

static/js/sectionfy.js
const API = 'https://sectionfy.codefy-apps.com/api/public/content';
/** Cache de promesas en vuelo, compartida por todas las secciones de la página. */
const enVuelo = new Map();
/**
* Trae el bundle de una página. Llamadas repetidas con la misma clave
* comparten un único request.
*/
export function traerBundle(token, page, targetId) {
const ruta = targetId ? `${token}/${page}/${targetId}` : `${token}/${page}`;
if (!enVuelo.has(ruta)) {
enVuelo.set(
ruta,
fetch(`${API}/${ruta}`)
.then((res) => {
if (!res.ok) throw new Error(`Sectionfy ${res.status}`);
return res.json();
})
.catch((error) => {
// Sacar la promesa fallida de la cache: un reintento posterior
// no debería recibir el mismo rechazo para siempre.
enVuelo.delete(ruta);
throw error;
}),
);
}
return enVuelo.get(ruta);
}
/** ¿Esta sección se muestra? Es la regla obligatoria del contrato. */
const visible = (s) => s.estado.lifecycle === 'active' && s.estado.storefrontVisible;
/**
* Secciones de un tipo, filtradas y ordenadas como las dejó el comerciante.
*
* @param kind - token en minúsculas: 'faq', 'image_carousel', 'specs', …
*/
export function seccionesDeTipo(bundle, kind) {
const esperado = kind.toUpperCase();
return Object.entries(bundle.sections)
.filter(([key, s]) =>
// `tipo` es el camino directo; parsear la clave es el respaldo para
// respuestas que no lo traigan.
s.tipo ? s.tipo === esperado : key.split(`-${bundle.page}-`)[0] === kind,
)
.filter(([, s]) => visible(s))
.sort(([, a], [, b]) => a.orden - b.orden)
.map(([key, seccion]) => ({ key, ...seccion }));
}
/** Escape mínimo para interpolar texto del comerciante en HTML. */
export function escapar(valor) {
return String(valor ?? '').replace(
/[&<>"']/g,
(c) =>
({
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#39;',
})[c],
);
}
static/js/sectionfy-faq.js
import { traerBundle, seccionesDeTipo, escapar } from './sectionfy.js';
export async function montarFaq(contenedor) {
const { token, page, targetId } = contenedor.dataset;
let bundle;
try {
bundle = await traerBundle(token, page, targetId);
} catch (error) {
// Degradar en silencio: es contenido complementario, no la ficha.
console.warn('[sectionfy] no se pudo cargar el contenido', error);
contenedor.remove();
return;
}
const faqs = seccionesDeTipo(bundle, 'faq');
// Sin contenido no dejamos un hueco en la maquetación.
if (faqs.length === 0) {
contenedor.remove();
return;
}
contenedor.innerHTML = faqs
.map((faq) => {
const items = faq.items
.slice()
.sort((a, b) => a.orden - b.orden)
.map(
(item) => `
<details class="sectionfy-faq__item">
<summary>${escapar(item.pregunta)}</summary>
<div class="sectionfy-faq__respuesta">${escapar(item.respuesta)}</div>
</details>
`,
)
.join('');
return `
<section class="sectionfy-faq" data-block="${faq.key}">
${faq.titulo ? `<h2 class="sectionfy-faq__titulo">${escapar(faq.titulo)}</h2>` : ''}
${items}
</section>
`;
})
.join('');
}

Declara el contenedor con los datos que la sección necesita, en el template donde va el contenido:

snipplets/product/sectionfy-faq.tpl
<div
id="sectionfy-faq"
data-token="{{ settings.sectionfy_token }}"
data-page="product"
data-target-id="{{ product.id }}"
></div>
<script type="module">
import { montarFaq } from '{{ "js/sectionfy-faq.js" | static_url }}';
const contenedor = document.getElementById('sectionfy-faq');
if (contenedor?.dataset.token) montarFaq(contenedor);
</script>

El if del final importa: si el comerciante todavía no pegó el token, la sección no intenta llamar a la API con un valor vacío.

Aquí se paga el diseño del módulo. Tres secciones distintas en la misma página:

<div
id="sectionfy-faq"
data-token="{{ settings.sectionfy_token }}"
data-page="product"
data-target-id="{{ product.id }}"
></div>
<div
id="sectionfy-specs"
data-token="{{ settings.sectionfy_token }}"
data-page="product"
data-target-id="{{ product.id }}"
></div>
<div
id="sectionfy-carrusel"
data-token="{{ settings.sectionfy_token }}"
data-page="product"
data-target-id="{{ product.id }}"
></div>
<script type="module">
import { montarFaq } from '{{ "js/sectionfy-faq.js" | static_url }}';
import { montarSpecs } from '{{ "js/sectionfy-specs.js" | static_url }}';
import { montarCarrusel } from '{{ "js/sectionfy-carrusel.js" | static_url }}';
montarFaq(document.getElementById('sectionfy-faq'));
montarSpecs(document.getElementById('sectionfy-specs'));
montarCarrusel(document.getElementById('sectionfy-carrusel'));
</script>

Las tres llaman a traerBundle con la misma ruta, así que se dispara una sola petición. Las otras dos esperan la misma promesa.

Si quieres que el orden en pantalla sea el que el comerciante armó arrastrando en el panel, no puedes montar cada tipo en un contenedor fijo. Necesitas un único contenedor y un despacho por tipo:

static/js/sectionfy-todo.js
import { traerBundle, escapar } from './sectionfy.js';
import { renderFaq } from './render/faq.js';
import { renderSpecs } from './render/specs.js';
import { renderCarrusel } from './render/carrusel.js';
const RENDERERS = {
FAQ: renderFaq,
SPECS: renderSpecs,
IMAGE_CAROUSEL: renderCarrusel,
// Un tipo sin renderer simplemente no se dibuja.
};
export async function montarTodo(contenedor) {
const { token, page, targetId } = contenedor.dataset;
const bundle = await traerBundle(token, page, targetId);
const enOrden = Object.entries(bundle.sections)
.filter(([, s]) => s.estado.lifecycle === 'active' && s.estado.storefrontVisible)
.sort(([, a], [, b]) => a.orden - b.orden);
for (const [key, seccion] of enOrden) {
const render = RENDERERS[seccion.tipo];
if (!render) continue;
contenedor.insertAdjacentHTML('beforeend', render({ key, ...seccion }, escapar));
}
}

Con este patrón, cuando el comerciante reordena en el panel, la tienda refleja el cambio sin tocar el tema.

Antes de dar por cerrada la integración:

  • El token se lee de un setting del tema, no está hardcodeado.
  • Toda interpolación de texto del comerciante pasa por escapar().
  • Se filtra por estado.lifecycle === 'active' && estado.storefrontVisible.
  • Se iteran todas las secciones del tipo, no solo la primera.
  • Los items se ordenan por orden.
  • Se guarda contra imagen === null antes de leer imagen.url.
  • Hay una sola petición por página, no una por sección.
  • Un error de la API no rompe la ficha ni deja un esqueleto colgado.
  • Sin contenido, el contenedor se saca en vez de quedar vacío.

Recetas de fetch para los casos que salen del molde: combinar plantilla e instancia, pedir dos endpoints en paralelo, y precargar un listado.