This is the full developer documentation for Sectionfy — API pública de contenido # API pública de contenido > Una llamada GET devuelve todas las secciones que el comerciante configuró para una página. Sin SDK, sin headers, sin build step. Traer el contenido de una página de producto ```bash curl https://sectionfy.codefy-apps.com/api/public/content/ndp_pub_a3f9k2mX7pLqR2wN/product/333333174 ``` GET y nada más No hay POST, ni PUT, ni DELETE. No hay headers de autenticación, ni bodies, ni negociación de contenido. Un `fetch()` del navegador alcanza. Un token en la URL La credencial es un token opaco con prefijo `ndp_pub_` que va como segmento del path. Es de solo lectura y está pensado para vivir en el código del tema. Una llamada por página La respuesta trae **todas** las secciones de esa página, de todos los tipos. Traes una vez y filtras en memoria — no una llamada por sección. Listo para agentes El sitio entero se sirve como markdown plano en [`/docs/llms-full.txt`](/docs/llms-full.txt). Un agente carga el contrato completo de una sola vez. ## Cómo se ve la respuesta [Sección titulada «Cómo se ve la respuesta»](#cómo-se-ve-la-respuesta) Un producto con dos secciones configuradas: unas preguntas frecuentes y un carrusel de especificaciones. GET /api/public/content/{token}/product/333333174 ```json { "page": "product", "product_id": "333333174", "sections": { "faq-product-333333174-cmqic4kt5000ieq2bk3hbiat3": { "titulo": "Preguntas frecuentes", "items": [ { "pregunta": "¿Hacen envíos a todo el país?", "respuesta": "Sí, con despacho en 24 a 72 horas hábiles.", "orden": 0 } ], "estado": { "lifecycle": "active", "storefrontVisible": true }, "orden": 0 }, "specs-product-333333174-cmqyi38ba0001b32c1hs2fb74": { "titulo": "Especificaciones", "items": [ { "imagen": { "url": "https://cdn.example.com/peso.png", "alt": null }, "titulo": "Peso", "descripcion": "1,2 kg", "orden": 0 } ], "estado": { "lifecycle": "active", "storefrontVisible": true }, "orden": 1 } } } ``` Cada clave de `sections` codifica el tipo, la ubicación y una identidad estable. Cómo leerla y cómo agrupar por tipo está en [la estructura de la respuesta](/docs/referencia/estructura-respuesta/). ## Por dónde seguir [Sección titulada «Por dónde seguir»](#por-dónde-seguir) Nunca la usaste Empieza por [qué es esta API](/docs/empezar/que-es/) y sigue hasta [tu primera llamada](/docs/empezar/primera-llamada/). Ya tienes el token Ve directo a [endpoints](/docs/referencia/endpoints/) y a [conectar una sección custom](/docs/guias/seccion-custom/). Buscas un shape concreto Cada tipo de sección tiene su página con el JSON real en [tipos de sección](/docs/referencia/secciones/). Eres un agente Empieza por [cómo consumir esta doc](/docs/agentes/consumir-la-doc/). # Consumir esta doc > Cómo un agente de IA carga el contrato completo de la API en un solo request, y las reglas que no puede inferir del JSON. Esta página es para agentes de IA y para las herramientas que los alimentan. Si eres una persona, lo que sigue también sirve: es el resumen más denso del sitio. ## Cargar la documentación completa [Sección titulada «Cargar la documentación completa»](#cargar-la-documentación-completa) Tres archivos, según cuánto contexto tengas disponible: | Archivo | Qué trae | Cuándo usarlo | | ---------------------------------------------- | -------------------------------------------------------- | -------------------------------------- | | [`/docs/llms.txt`](/docs/llms.txt) | Índice con la descripción y los enlaces a cada página. | Para decidir qué leer. | | [`/docs/llms-small.txt`](/docs/llms-small.txt) | El sitio entero en markdown, sin los bloques accesorios. | Contexto acotado. | | [`/docs/llms-full.txt`](/docs/llms-full.txt) | El sitio entero en markdown, completo. | **El default.** Trae todo el contrato. | También hay un volcado acotado a los shapes de sección: [`/docs/_llms-txt/referencia-de-secciones.txt`](/docs/_llms-txt/referencia-de-secciones.txt). ```bash curl https://sectionfy.codefy-apps.com/docs/llms-full.txt ``` Además, cualquier página del sitio se puede pedir como markdown crudo agregando `.md` a su ruta: ```bash curl https://sectionfy.codefy-apps.com/docs/referencia/estructura-respuesta.md ``` ## El contrato máquina [Sección titulada «El contrato máquina»](#el-contrato-máquina) Para generar clientes tipados o alimentar herramientas que hablen OpenAPI: ```plaintext https://sectionfy.codefy-apps.com/api/public/content/openapi.json ``` Cubre los endpoints del bundle de contenido. Esta documentación es un superconjunto: incluye además las reglas de comportamiento que un esquema no puede expresar. ## Lo esencial en veinte líneas [Sección titulada «Lo esencial en veinte líneas»](#lo-esencial-en-veinte-líneas) ```plaintext BASE: https://sectionfy.codefy-apps.com AUTH: token opaco `ndp_pub_…` como segmento de la URL. Sin headers. Solo GET. RUTAS /api/public/content/{token}/{page} page ∈ {home-page, category-page, product-page} /api/public/content/{token}/{page}/{targetId} page ∈ {product, category} /api/public/content/{token}/products/all → { page, products: [...] } (ARRAY) /api/public/key-features-banner/{token} → { sections } (sin `page`) /api/public/promo-modal/{token} → { sections } (sin `page`; placement=home|category|product, target_id en category/product) /api/public/featured-benefits/{token} → { sections } (sin `page`; placement=home|category|product, target_id en category/product) /api/public/video-banner/{token} → { sections } (sin `page`; placement=home|category|product, target_id en category/product) /api/public/video-reviews/{token} → { sections } (sin `page`; placement=home|category|product, target_id en category/product) RESPUESTA: { page, product_id?, category_id?, sections: { [clave]: Seccion } } `page` viene NORMALIZADO: home-page→home, category-page→category, product-page→product clave = `{kind}-{placement}-{targetId|general}-{blockId}` placement ∈ {home, category, product} — idéntico a `page` SECCION: { tipo, titulo, items[], estado, orden, indice?, nombre_personalizado? } `tipo` = discriminador en MAYUSCULAS (FAQ, RICH_DESCRIPTION, …). Usarlo para despachar. `items` SIEMPRE es array, nunca null. Puede estar vacío. REGLA DE RENDER (obligatoria) renderizar solo si estado.lifecycle === 'active' && estado.storefrontVisible === true ``` ## Las siete reglas que el JSON no te dice [Sección titulada «Las siete reglas que el JSON no te dice»](#las-siete-reglas-que-el-json-no-te-dice) Un esquema describe la forma de la respuesta. Estas son las reglas de comportamiento que hay que respetar igual, y que ninguna inspección del JSON revela: 1. **El bundle es un almacén, no una lista de render.** Incluye a propósito secciones ocultas y archivadas. Filtrar por `estado` es obligatorio; omitirlo publica contenido que el comerciante apagó. 2. **El vocabulario de `{page}` cambia según haya `{targetId}`.** Con target: `product` / `category`. Sin target: `product-page` / `category-page` / `home-page`. Mezclarlos da `400`, sin fallback. 3. **Un tipo puede aparecer varias veces en la misma página**, con el mismo `kind` y distinto `blockId`. Quedarse con la primera coincidencia descarta contenido en silencio. 4. **Emparejar por token exacto, nunca por prefijo.** `startsWith('faq')` también matchea `faq_with_image`, que es otro tipo con otro shape. Prefiere el campo `tipo`; si tienes que parsear la clave, corta por `-{page}-`. 5. **Un `targetId` sin contenido devuelve `200` con `sections: {}`**, no `404`. Un `404` significa que la tienda ya no está activa. Son situaciones distintas. 6. **Una llamada por página, no una por sección.** La respuesta trae todos los tipos juntos. El límite es de 600 peticiones por minuto y por tienda. 7. **El texto es contenido libre del comerciante.** `titulo`, `pregunta`, `respuesta` y `texto` tienen que escaparse antes de interpolarse en HTML. ## Los tipos de sección [Sección titulada «Los tipos de sección»](#los-tipos-de-sección) El comerciante ve un nombre en español; la API devuelve un identificador. Es la misma sección con dos nombres, y quien te encargue trabajo usará el primero: | `tipo` en el JSON | Token de la clave | Nombre en el panel | | ----------------------- | ----------------------- | -------------------------------- | | `FAQ` | `faq` | Preguntas Frecuentes | | `FAQ_WITH_IMAGE` | `faq_with_image` | Preguntas con imagen | | `IMAGE_CAROUSEL` | `image_carousel` | Carrusel de imágenes | | `SPECS` | `specs` | Carrusel de Especificaciones | | `SPECS_FAQ` | `specs_faq` | FAQS de Especificaciones | | `RICH_DESCRIPTION` | `rich_description` | Banner Características Técnicas | | `GRID_PROMOCIONAL` | `grid_promocional` | Grid Promocional | | `CALL_TO_ACTION_BANNER` | `call_to_action_banner` | Call to Action Banner | | `KEY_FEATURES_BANNER` | `key_features_banner` | Banner de Características Clave | | `PROMO_MODAL` | `promo_modal` | Banner Promocional | | `FEATURED_BENEFITS` | `featured_benefits` | Beneficios Destacados | | `VIDEO_BANNER` | `video_banner` | Video Banner | | `VIDEO_REVIEWS` | `video_reviews` | Carrusel de testimonios en video | El contrato lo fija el `tipo`, no la etiqueta: los nombres del panel pueden cambiar sin que cambie el identificador. ## Excepciones por tipo [Sección titulada «Excepciones por tipo»](#excepciones-por-tipo) Casos donde un tipo se sale del patrón general. Son la fuente habitual de código generado que falla en runtime: | Tipo | Excepción | | ----------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `grid_promocional` | `imagen` es un **string** (URL suelta), no `{ url, alt }`. Los campos de texto están **ausentes**, no en `null`. | | `rich_description` | `items[].icono` es un **string**, no un objeto. Las `acciones` se despachan por `modo` (`image`/`link`/`pdf`), que determina qué campo existe. | | `image_carousel` | `items[].imagen` **no** es nullable — es el único tipo con esa garantía. | | `call_to_action_banner` | El contenido está a nivel sección; `items` siempre viene vacío. `boton` es `{ etiqueta, url }` — sin `nueva_pestana`. | | `specs` | `titulo` y `descripcion` son requeridos. | | `key_features_banner` | Endpoint propio, no llega en el bundle. `titulo` y `descripcion` de cada item **sí** pueden ser `null`. | | `promo_modal` | Sobre genérico, sin items: los campos van en `campos`. `buttonUrl` es siempre `https://` o `null`; `campos.imageUrl` puede ser un video (`imageMimeType: video/…`). | | `featured_benefits` | Sobre genérico: los campos van en `campos`. `campos.imageUrl` puede ser un video (`imageMimeType: video/…`); el título editable es `campos.heading`. | | `video_banner` | Sobre genérico: los campos van en `campos`. `campos.videoUrl` es siempre un video (`videoMimeType: video/…`) o `null`; cada botón trae `url` `https://` o `null`. | | `video_reviews` | Sobre genérico: los campos van en `campos`. `campos.mediaUrl` puede ser un video (`mediaMimeType: video/…`) o `null` si el archivo no está `READY` (omite el item); la descripción de la sección es `campos.descripcion`; `productLinkUrl` es siempre `https://` o `null`. | ## Cache [Sección titulada «Cache»](#cache) ```plaintext Cache-Control: public, max-age=0, s-maxage=60, stale-while-revalidate=60 ETag: W/"…" → reenviar como If-None-Match; un 304 no trae cuerpo ``` El `ETag` es opaco: compararlo y reenviarlo, nunca parsearlo. Un cambio del comerciante puede tardar hasta \~2 minutos en propagarse por la caché de borde. ## Errores [Sección titulada «Errores»](#errores) | Código | Causa | ¿Reintentar? | | ------ | ----------------------------------------------------------- | --------------- | | `400` | Token con formato inválido, o `page` fuera del vocabulario. | No | | `401` | Token bien formado pero inexistente. | No | | `403` | Origen no permitido. | No | | `404` | La tienda ya no está activa. | No | | `429` | Límite de peticiones superado. | Sí, con espera | | `500` | Error del servidor. | Sí, con backoff | Cuerpo de error: `{ statusCode, message, error? }`. `message` es lo único que distingue dos errores del mismo código. # Modelo mental > Cuatro ideas que explican por qué la API tiene la forma que tiene, y que evitan casi todos los errores de integración. Cuatro ideas. Si las tienes claras, el resto de la referencia se lee sola. ## 1. Una llamada trae la página entera [Sección titulada «1. Una llamada trae la página entera»](#1-una-llamada-trae-la-página-entera) No hay un endpoint por tipo de sección. Pides una página y recibes **todas** las secciones que el comerciante configuró ahí, de todos los tipos, mezcladas en un mismo objeto `sections`. La consecuencia práctica: **trae una vez por página y filtra en memoria**. Si tu tema tiene tres secciones custom, las tres comparten el mismo fetch. Hacer una llamada por sección multiplica la latencia sin traer nada nuevo. ```js // Una sola promesa compartida por toda la página. window.__sectionfy ??= fetch(`${API}/${TOKEN}/product/${productId}`).then((r) => r.json()); // Cada sección del tema la reutiliza. const bundle = await window.__sectionfy; ``` ## 2. El bundle es un almacén, no una lista de render [Sección titulada «2. El bundle es un almacén, no una lista de render»](#2-el-bundle-es-un-almacén-no-una-lista-de-render) Esta es la que más sorprende. La respuesta incluye secciones que el comerciante **ocultó** y secciones que **archivó**. Estar en el bundle no significa “muéstrame”. Cada sección trae un `estado`: ```json "estado": { "lifecycle": "active", "storefrontVisible": true } ``` La regla, sin excepciones: ```js const visible = (s) => s.estado.lifecycle === 'active' && s.estado.storefrontVisible; ``` Se diseñó así para que el comerciante pueda apagar y encender contenido sin perderlo, y para que un tema pueda previsualizar contenido oculto si lo decide explícitamente. Pero el default correcto es filtrar. ## 3. Un tipo puede aparecer varias veces [Sección titulada «3. Un tipo puede aparecer varias veces»](#3-un-tipo-puede-aparecer-varias-veces) Un producto puede tener dos bloques de FAQ distintos, o tres carruseles. Cada instancia es una entrada propia en `sections`, con el mismo `kind` y distinto `blockId`. ```json { "faq-product-333-cmqic4kt5000ieq2bk3hbiat3": { "titulo": "Envíos", "orden": 0 }, "faq-product-333-cmqxb91mn0007ab1c9dk4mz2p": { "titulo": "Garantía", "orden": 2 } } ``` Quedarte solo con la primera coincidencia descarta contenido silenciosamente. Itera todas, ordénalas por `orden`, y si necesitas distinguirlas usa `indice` (presente solo cuando hay dos o más del mismo tipo en el mismo alcance). ## 4. Estructura en inglés, contenido en español [Sección titulada «4. Estructura en inglés, contenido en español»](#4-estructura-en-inglés-contenido-en-español) Las claves que definen la forma de la respuesta están en inglés: `sections`, `page`, `product_id`, `estado`, `lifecycle`, `storefrontVisible`. Las claves que llevan contenido del comerciante están en español: `titulo`, `texto`, `imagen`, `items`, `pregunta`, `respuesta`, `enlace`, `boton`, `orden`. No es inconsistencia: el contenido lo escribe un comerciante hispanohablante y las claves espejan las etiquetas que ve en el panel. Una vez que lo sabes, puedes predecir el nombre de un campo sin mirar la referencia. ## Dos consecuencias que valen oro [Sección titulada «Dos consecuencias que valen oro»](#dos-consecuencias-que-valen-oro) **`items` siempre es un array.** Aunque esté vacío. Nunca es `null`, así que puedes hacer `.map()` sin guardar. **Las imágenes pueden ser `null`.** Solo se incluyen los assets que terminaron de procesarse. Un `imagen: null` es un estado normal, no un error — guarda siempre antes de leer `imagen.url`. ```js const src = item.imagen?.url; if (!src) return; // omitir, no romper ``` ## Siguiente paso [Sección titulada «Siguiente paso»](#siguiente-paso) La [referencia de endpoints](/docs/referencia/endpoints/). # Tu primera llamada > De cero a contenido renderizado — curl, fetch, y cómo leer lo que vuelve. Necesitas dos cosas: el [token](/docs/empezar/token/) y el ID de un producto de la tienda. El ID de producto es el mismo que usa Tiendanube y está disponible en el template del tema como `{{ product.id }}`. ## 1. Traer el contenido [Sección titulada «1. Traer el contenido»](#1-traer-el-contenido) ```bash curl https://sectionfy.codefy-apps.com/api/public/content/ndp_pub_a3f9k2mX7pLqR2wN/product/333333174 ``` Tres piezas en esa URL: | Segmento | Valor | Qué es | | ------------ | -------------------------- | --------------------- | | `{token}` | `ndp_pub_a3f9k2mX7pLqR2wN` | La tienda. | | `{page}` | `product` | El tipo de página. | | `{targetId}` | `333333174` | El producto concreto. | Ten en cuenta el vocabulario de `{page}`: **cambia según lleve o no `{targetId}`**. Con target se usa `product` / `category`; sin target se usa `product-page` / `category-page` / `home-page`. Es el error más común y no hay fallback: si los mezclas, la API responde `400`. Está detallado en [endpoints](/docs/referencia/endpoints/#el-vocabulario-de-page). ## 2. Lo mismo desde el tema [Sección titulada «2. Lo mismo desde el tema»](#2-lo-mismo-desde-el-tema) ```js const TOKEN = window.SECTIONFY_TOKEN; const API = 'https://sectionfy.codefy-apps.com/api/public/content'; const res = await fetch(`${API}/${TOKEN}/product/333333174`); if (!res.ok) throw new Error(`Sectionfy ${res.status}`); const bundle = await res.json(); ``` No hace falta pasar headers, ni `credentials`: es un GET simple. ## 3. Lo que vuelve [Sección titulada «3. Lo que vuelve»](#3-lo-que-vuelve) ```json { "page": "product", "product_id": "333333174", "sections": { "faq-product-333333174-cmqic4kt5000ieq2bk3hbiat3": { "titulo": "Preguntas frecuentes", "items": [ { "pregunta": "¿Hacen envíos a todo el país?", "respuesta": "Sí, con despacho en 24 a 72 horas hábiles.", "orden": 0 } ], "estado": { "lifecycle": "active", "storefrontVisible": true }, "orden": 0 } } } ``` `sections` es un **objeto**, no un array. Cada clave codifica cuatro cosas separadas por guiones: ```plaintext faq-product-333333174-cmqic4kt5000ieq2bk3hbiat3 └┬┘ └──┬──┘ └───┬───┘ └───────────┬────────────┘ │ │ │ └─ blockId: identidad estable de esta sección │ │ └─ targetId: el producto (o el literal `general` si no aplica) │ └─ placement: la página └─ kind: el tipo de sección ``` ## 4. Sacar las secciones de un tipo [Sección titulada «4. Sacar las secciones de un tipo»](#4-sacar-las-secciones-de-un-tipo) Cada sección trae además un campo `tipo` con su identificador, así que quedarte con todos los FAQ son tres pasos: filtrar por tipo, aplicar la regla de render, y ordenar. ```js const faqs = Object.entries(bundle.sections) // 1. El tipo que buscas, en MAYUSCULAS. .filter(([, s]) => s.tipo === 'FAQ') // 2. Regla de render: el bundle trae secciones ocultas y archivadas. .filter(([, s]) => s.estado.lifecycle === 'active' && s.estado.storefrontVisible) // 3. El comerciante define el orden arrastrando en el panel. .sort(([, a], [, b]) => a.orden - b.orden) .map(([key, seccion]) => ({ key, ...seccion })); ``` Esos tres pasos son iguales para todos los tipos, así que conviene envolverlos en un helper. En el resto de la documentación aparece como `seccionesDeTipo(bundle, kind)` y está escrito en [el módulo cliente](/docs/guias/seccion-custom/#1-el-m%C3%B3dulo-cliente). La regla de render no es opcional La respuesta es un almacén, no una lista de render: incluye a propósito las secciones que el comerciante **ocultó** (`storefrontVisible: false`) y las que **archivó** (`lifecycle: "archived"`). Si renderizas sin filtrar, publicas contenido que el comerciante apagó. ## 5. Renderizar [Sección titulada «5. Renderizar»](#5-renderizar) ```js for (const faq of faqs) { const detalles = faq.items .slice() .sort((a, b) => a.orden - b.orden) .map( (item) => `
${item.pregunta}

${item.respuesta}

`, ) .join(''); contenedor.insertAdjacentHTML( 'beforeend', `
${faq.titulo ? `

${faq.titulo}

` : ''} ${detalles}
`, ); } ``` Antes de llevarlo a producción, escapa el texto Ese snippet interpola contenido del comerciante directo en `innerHTML` para que se lea de un vistazo. En un tema real no alcanza: `pregunta`, `respuesta`, `titulo` y `texto` son texto libre cargado desde un panel, y sin escapar cualquier cosa que alguien pegue ahí se ejecuta en la tienda. El resto de los ejemplos de esta documentación usan un helper `escapar()` — [está aquí](/docs/guias/seccion-custom/#1-el-m%C3%B3dulo-cliente). Eso es todo el ciclo. El resto de la documentación es detalle: qué otros endpoints hay, qué shape tiene cada tipo de sección, y cómo cachear bien. ## Siguiente paso [Sección titulada «Siguiente paso»](#siguiente-paso) El [modelo mental](/docs/empezar/modelo-mental/) — cuatro ideas que explican casi todas las decisiones de esta API. # Qué es esta API > Qué devuelve la API pública de contenido de Sectionfy, para quién es y qué no hace. Sectionfy es una app de Tiendanube donde el comerciante arma contenido enriquecido para las páginas de su tienda: preguntas frecuentes, carruseles de imágenes, fichas de especificaciones, banners promocionales. Esta API expone ese contenido para que **un tema custom lo renderice a su manera**. La app no impone HTML ni estilos: te devuelve datos y tú decides cómo se ven. ## Para quién es [Sección titulada «Para quién es»](#para-quién-es) Para quien escribe el código de un tema de Tiendanube y necesita mostrar el contenido que el comerciante cargó, sin acoplarse al panel de la app. No necesitas una cuenta de desarrollador, ni registrar una app, ni pasar por OAuth. Necesitas una sola cosa: el [token público](/docs/empezar/token/) de la tienda. ## Qué devuelve [Sección titulada «Qué devuelve»](#qué-devuelve) Una llamada devuelve el conjunto completo de secciones configuradas para una página concreta: la ficha de un producto, la de una categoría, la home, o la plantilla general de todos los productos. ```json { "page": "product", "product_id": "333333174", "sections": { "…": "una entrada por sección configurada" } } ``` Dentro de `sections` conviven tipos distintos. Un producto puede tener a la vez un FAQ, un carrusel y un banner; todos llegan en la misma respuesta. ## Qué NO hace [Sección titulada «Qué NO hace»](#qué-no-hace) Vale la pena ser explícito, porque ahorra tiempo buscando: | No hace | En su lugar | | -------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Escribir contenido | La API es de solo lectura. El contenido se carga desde el panel de la app. | | Devolver HTML renderizado | Devuelve JSON. El markup lo pones tú. | | Filtrar por tipo de sección | Devuelve todos los tipos de una página; filtras en el cliente. | | Paginar | Las respuestas son completas. `products/all` devuelve todos los productos con contenido. | | Buscar o consultar por atributos | No hay query params de filtrado. El path determina la respuesta entera. | | Decidir qué mostrar | Devuelve secciones ocultas y archivadas también. La regla de render es tuya y **es obligatoria** — ver [la estructura de la respuesta](/docs/referencia/estructura-respuesta/#la-regla-de-render). | ## Forma de la API [Sección titulada «Forma de la API»](#forma-de-la-api) * **Solo GET.** No hay verbos de escritura públicos. * **Sin headers de autenticación.** El token va en el path. * **Sin bodies.** Ni de request ni en verbos alternativos. * **JSON siempre.** `Content-Type: application/json`. * **Cacheable.** Respeta `Cache-Control` y `ETag` — ver [cache](/docs/referencia/cache/). Es deliberadamente aburrida: se consume con un `fetch()` de una línea desde el navegador, sin SDK ni build step. ## Siguiente paso [Sección titulada «Siguiente paso»](#siguiente-paso) [Obtener el token público](/docs/empezar/token/) de la tienda con la que trabajarás. # Obtener el token público > Qué es el token ndp_pub, de dónde sale, cómo se usa en la URL y qué precauciones tomar al ponerlo en el código de un tema. El token público es la única credencial de esta API. Identifica a una tienda y habilita la lectura de su contenido. ```plaintext ndp_pub_a3f9k2mX7pLqR2wN ``` ## De dónde sale [Sección titulada «De dónde sale»](#de-dónde-sale) Lo genera la app y lo ve el comerciante en el panel de Sectionfy. Si estás desarrollando un tema para un cliente, **pídeselo al comerciante**: es él quien lo tiene a mano. Un token pertenece a una tienda. No hay tokens globales ni tokens que abarquen varias tiendas. ## Cómo se usa [Sección titulada «Cómo se usa»](#cómo-se-usa) Va como un segmento del path, siempre inmediatamente después de la raíz del recurso: ```plaintext https://sectionfy.codefy-apps.com/api/public/content/{token}/{page} ^^^^^^^ ``` No va en un header `Authorization`, ni en un query param, ni en una cookie. Si lo envías en un header, la petición falla igual que si no lo hubieras enviado. ## Qué puede y qué no puede [Sección titulada «Qué puede y qué no puede»](#qué-puede-y-qué-no-puede) | | | | ------------ | --------------------------------------------------------------------------- | | **Puede** | Leer el contenido publicado de esa tienda. | | **No puede** | Escribir, borrar, ni modificar nada. | | **No puede** | Acceder a datos de otra tienda. | | **No puede** | Acceder a datos de clientes, pedidos, ni a nada de la cuenta de Tiendanube. | Es un token de lectura de contenido de marketing. El peor escenario si se filtra es que alguien lea el contenido que de todos modos es visible en la tienda. ## Ponerlo en el código de un tema [Sección titulada «Ponerlo en el código de un tema»](#ponerlo-en-el-código-de-un-tema) El token va a terminar en el JavaScript del tema, que es público. Eso está contemplado en el diseño: por eso el token es opaco y de solo lectura. Aun así, la forma recomendada es **declararlo como un setting del tema** en vez de hardcodearlo, para que el comerciante lo pegue y lo pueda rotar sin tocar código: config/settings.txt ```txt [ { "name": "Sectionfy", "settings": [ { "name": "sectionfy_token", "title": "Token público de Sectionfy", "type": "text" } ] } ] ``` Y en el template, con un fallback por si el setting aún no está cargado: snipplets/sectionfy.tpl ```html ``` Es opaco, no es secreto No intentes ofuscarlo ni pasarlo por un proxy propio: no gana nada. Lo que sí importa es no confundirlo con el token privado de la app — ese nunca sale del backend y no aparece en ninguna parte de esta documentación. ## Validar que funciona [Sección titulada «Validar que funciona»](#validar-que-funciona) La forma más rápida es una llamada a la home de la tienda, que no necesita ningún identificador extra: ```bash curl -i https://sectionfy.codefy-apps.com/api/public/content/TU_TOKEN/home-page ``` * `200` con un cuerpo JSON → el token es válido. * `400 token has invalid format` → el token está mal escrito. Se rechaza por forma, antes de buscarlo. * `401` → el formato es válido pero el token no corresponde a ninguna tienda. Los códigos completos están en [errores](/docs/referencia/errores/), y puedes probarlo sin escribir código en el [probador](/docs/playground/). ## Siguiente paso [Sección titulada «Siguiente paso»](#siguiente-paso) [Tu primera llamada](/docs/empezar/primera-llamada/). # Recetas de fetch > Patrones listos para copiar — una llamada por página, combinar plantilla e instancia, paralelizar endpoints y precargar listados. Patrones concretos para los casos que salen del molde. Todos asumen el módulo `sectionfy.js` de la [guía de sección custom](/docs/guias/seccion-custom/#1-el-m%C3%B3dulo-cliente). ## Una sola llamada por página [Sección titulada «Una sola llamada por página»](#una-sola-llamada-por-página) El patrón base, y el que más impacto tiene: `traerBundle` cachea la **promesa**, no el resultado. La diferencia no es un detalle de estilo. Si cacheas el resultado, queda una ventana abierta entre que arranca el fetch y que termina: durante esos milisegundos la cache sigue vacía, así que la segunda y la tercera sección disparan sus propios requests. Cacheando la promesa, las tres esperan la misma. La implementación está en [el módulo cliente](/docs/guias/seccion-custom/#1-el-m%C3%B3dulo-cliente). Todo lo que sigue la asume. ## Combinar plantilla e instancia [Sección titulada «Combinar plantilla e instancia»](#combinar-plantilla-e-instancia) Tanto una ficha de producto como una página de categoría tienen dos niveles de contenido: | Página | Lo específico de **ese** recurso | Lo común a **todas** | | --------- | -------------------------------- | ------------------------ | | Producto | `/{token}/product/{id}` | `/{token}/product-page` | | Categoría | `/{token}/category/{id}` | `/{token}/category-page` | Si tu tema usa los dos, pídelos en paralelo y únelos: ```js export async function contenidoDeFicha(token, productId) { const [instancia, plantilla] = await Promise.all([ traerBundle(token, 'product', productId), traerBundle(token, 'product-page'), ]); // Las claves nunca chocan: la instancia lleva el productId donde la // plantilla lleva el literal `general`. return { page: 'product', product_id: productId, sections: { ...plantilla.sections, ...instancia.sections }, }; } ``` La categoría es la misma función con otros dos argumentos: ```js export async function contenidoDeCategoria(token, categoryId) { const [instancia, plantilla] = await Promise.all([ traerBundle(token, 'category', categoryId), traerBundle(token, 'category-page'), ]); return { page: 'category', category_id: categoryId, sections: { ...plantilla.sections, ...instancia.sections }, }; } ``` `orden` es relativo a cada respuesta Los dos bundles numeran su `orden` desde cero de forma independiente, así que al fusionarlos puedes tener dos secciones con `orden: 0`. Si el orden entre niveles te importa, decide una política explícita — por ejemplo, plantilla siempre arriba: ```js const conOffset = (sections, offset) => Object.fromEntries( Object.entries(sections).map(([k, s]) => [k, { ...s, orden: s.orden + offset }]), ); const sections = { ...conOffset(plantilla.sections, 0), ...conOffset(instancia.sections, 1000), }; ``` Recuerda que los dos responden `"page": "product"`. No uses ese campo para saber cuál es cuál. ## Dos endpoints en paralelo [Sección titulada «Dos endpoints en paralelo»](#dos-endpoints-en-paralelo) [`key_features_banner`](/docs/referencia/secciones/banner-de-caracteristicas-clave/) vive en su propio endpoint. Si la página necesita las dos cosas, no las encadenes: ```js const API = 'https://sectionfy.codefy-apps.com/api/public'; export async function todoElContenido(token, productId) { const [bundle, banners] = await Promise.all([ fetch(`${API}/content/${token}/product/${productId}`).then((r) => r.json()), fetch(`${API}/key-features-banner/${token}`).then((r) => r.json()), ]); return { bundle, banners }; } ``` Si quieres que un fallo del banner no eche abajo la ficha, usa `Promise.allSettled`: ```js const [bundleRes, bannersRes] = await Promise.allSettled([...]); const bundle = bundleRes.status === 'fulfilled' ? bundleRes.value : { sections: {} }; const banners = bannersRes.status === 'fulfilled' ? bannersRes.value : { sections: {} }; ``` Devolver `{ sections: {} }` como respaldo deja que el resto del código siga sin ramas especiales. ## Precargar un listado [Sección titulada «Precargar un listado»](#precargar-un-listado) En una página de categoría o en un buscador, `products/all` te trae todos los productos con contenido en una llamada, en vez de una por tarjeta: ```js export async function indiceDeProductos(token) { const res = await fetch(`${API}/content/${token}/products/all`); if (!res.ok) throw new Error(`Sectionfy ${res.status}`); const { products } = await res.json(); // `products` es un ARRAY. Indexarlo hace las búsquedas O(1). return new Map(products.map((p) => [p.product_id, p.sections])); } const indice = await indiceDeProductos(TOKEN); const secciones = indice.get('333333174') ?? {}; ``` Ten en cuenta el tamaño: no pagina, así que en un catálogo grande el payload puede ser pesado. Vale la pena si necesitas varios productos; para uno solo, pide `/{token}/product/{id}`. ## Traer contenido de la home [Sección titulada «Traer contenido de la home»](#traer-contenido-de-la-home) La home no lleva `targetId`: ```js const bundle = await traerBundle(token, 'home-page'); // bundle.page === 'home' (normalizado, sin el sufijo) ``` ## Pintar las secciones de una categoría [Sección titulada «Pintar las secciones de una categoría»](#pintar-las-secciones-de-una-categoría) De punta a punta, en una plantilla de categoría. El `categoryId` lo da tu tema —en Tiendanube, `category.id`— y las secciones se filtran con los mismos dos helpers compartidos que usa cualquier otra página: ```js const bundle = await traerBundle(token, 'category', categoryId); // seccionesDeTipo ya aplica la regla de render y ordena por `orden`. for (const seccion of seccionesDeTipo(bundle, 'FAQ')) { const html = seccion.items .map((item) => `
${escapar(item.pregunta)}
${escapar(item.respuesta)}
`) .join(''); document.querySelector('#ndp-faqs').innerHTML = `
${html}
`; } ``` Una categoría sin contenido configurado responde `200` con `sections: {}`, no `404` — así que el bucle simplemente no itera y no necesitas una rama para el caso vacío. ## Con TypeScript [Sección titulada «Con TypeScript»](#con-typescript) Los tipos mínimos que hacen falta para trabajar cómodo: ```ts type Imagen = { url: string; alt: string | null }; type Estado = { lifecycle: 'active' | 'archived' | string; storefrontVisible: boolean; }; type Seccion = { tipo?: string; titulo: string | null; nombre_personalizado?: string; texto?: string | null; imagen?: Imagen | null; items: unknown[]; estado: Estado; orden: number; indice?: number; }; type Bundle = { page: 'home' | 'category' | 'product'; product_id?: string; category_id?: string; sections: Record; }; ``` `items` va como `unknown[]` a propósito: su shape depende del tipo de sección. Restríngelo cuando ya sabes con qué tipo estás trabajando: ```ts type FaqItem = { pregunta: string; respuesta: string; orden: number }; const faqs = seccionesDeTipo(bundle, 'faq') as Array; ``` Si prefieres tipos generados en vez de escritos a mano, el [contrato OpenAPI](/docs/referencia/endpoints/#contrato-m%C3%A1quina) sirve de entrada para cualquier generador. ## Verificar sin escribir código [Sección titulada «Verificar sin escribir código»](#verificar-sin-escribir-código) Antes de conectar nada, mira qué devuelve realmente el token con el que estás trabajando: el [probador](/docs/playground/) muestra la respuesta y los headers de cache. # Conectar una sección custom > De cero a una sección propia renderizando contenido de la API en un tema de Tiendanube. 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. ## 1. El módulo cliente [Sección titulada «1. El módulo cliente»](#1-el-módulo-cliente) 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 ```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) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''', })[c], ); } ``` El escape no es opcional `titulo`, `pregunta`, `respuesta` y `texto` son texto libre cargado por el comerciante. Si los interpolas con `innerHTML` sin escapar, cualquier cosa que alguien pegue en el panel se ejecuta en la tienda. Usa `escapar()` en **todos** los valores de contenido, o construye los nodos con `textContent`. ## 2. La sección [Sección titulada «2. La sección»](#2-la-sección) static/js/sectionfy-faq.js ```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) => `
${escapar(item.pregunta)}
${escapar(item.respuesta)}
`, ) .join(''); return `
${faq.titulo ? `

${escapar(faq.titulo)}

` : ''} ${items}
`; }) .join(''); } ``` ## 3. El cableado en el tema [Sección titulada «3. El cableado en el tema»](#3-el-cableado-en-el-tema) Declara el contenedor con los datos que la sección necesita, en el template donde va el contenido: snipplets/product/sectionfy-faq.tpl ```html
``` 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. ## 4. Varias secciones, un solo fetch [Sección titulada «4. Varias secciones, un solo fetch»](#4-varias-secciones-un-solo-fetch) Aquí se paga el diseño del módulo. Tres secciones distintas en la misma página: ```html
``` Las tres llaman a `traerBundle` con la misma ruta, así que se dispara **una sola petición**. Las otras dos esperan la misma promesa. ## 5. Respetar el orden del comerciante [Sección titulada «5. Respetar el orden del comerciante»](#5-respetar-el-orden-del-comerciante) 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 ```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. ## Checklist [Sección titulada «Checklist»](#checklist) 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. ## Siguiente paso [Sección titulada «Siguiente paso»](#siguiente-paso) [Recetas de fetch](/docs/guias/recetas-fetch/) para los casos que salen del molde: combinar plantilla e instancia, pedir dos endpoints en paralelo, y precargar un listado. # Cache, ETag y límites > Qué headers de cache emite la API, cómo usar ETag para ahorrar transferencia, y cuál es el límite de peticiones. Las respuestas de contenido son cacheables y traen validador. Si respetas los headers, la mayoría de las visitas repetidas no transfieren cuerpo. ## Headers de respuesta [Sección titulada «Headers de respuesta»](#headers-de-respuesta) ```http HTTP/1.1 200 OK Content-Type: application/json Cache-Control: public, max-age=0, s-maxage=60, stale-while-revalidate=60 ETag: W/"…" ``` | Directiva | Qué hace | | --------------------------- | -------------------------------------------------------------------------------------------------------------------------- | | `max-age=0` | El **navegador** siempre revalida. Nunca sirve una copia local sin preguntar. | | `s-maxage=60` | Las **cachés compartidas** (CDN) pueden servir la respuesta hasta 60 segundos sin consultar al origen. | | `stale-while-revalidate=60` | Pasados esos 60 segundos, la CDN puede seguir sirviendo la copia vieja hasta 60 segundos más mientras refresca por detrás. | La consecuencia práctica: **un cambio del comerciante puede tardar hasta unos dos minutos en verse en todos lados**. No es un bug; es el margen de la caché de borde. Del lado del origen hay una segunda caché, por tienda y por tipo de sección (`content:{store}:{modulo}`, con módulos como `image-carousel` o `spec-carousel`). Cada edición del comerciante —contenido, visibilidad, orden, archivado o estilo— invalida únicamente las entradas de **su** tienda, así que el contenido nuevo entra en la respuesta en cuanto la caché de borde revalida. No hay nada que purgar desde el tema. ## Revalidar con ETag [Sección titulada «Revalidar con ETag»](#revalidar-con-etag) El `ETag` es un validador opaco. Guárdalo y devuélvelo en la siguiente petición: ```http GET /api/public/content/{token}/product/333333174 If-None-Match: W/"…" ``` Si nada cambió, la API responde: ```http HTTP/1.1 304 Not Modified Cache-Control: public, max-age=0, s-maxage=60, stale-while-revalidate=60 ETag: W/"…" ``` Sin cuerpo. Ahorras toda la transferencia del JSON. Es opaco: no lo interpretes Trata el `ETag` como una cadena sin estructura. Compararlo por igualdad y reenviarlo es todo lo que se espera de un cliente. Su formato interno puede cambiar sin aviso. ### En el navegador: no lo manejes manualmente [Sección titulada «En el navegador: no lo manejes manualmente»](#en-el-navegador-no-lo-manejes-manualmente) Desde un tema, **deja que el navegador se encargue**. Su caché HTTP envía el `If-None-Match` por su cuenta y te entrega el cuerpo cacheado cuando llega un `304`. Desde tu código eso se ve como un `200` normal, sin que tengas que guardar nada. Es una razón para no desactivar la caché con `cache: 'no-store'`: pierdes la revalidación gratuita. Dos límites de CORS que sorprenden Esta API acepta pedidos de cualquier origen (`Access-Control-Allow-Origin: *`, por eso tampoco admite `credentials: 'include'`), pero con la configuración mínima. Eso tiene dos consecuencias en el navegador: **No puedes leer el `ETag` por JavaScript.** Los pedidos cross-origin solo exponen un puñado de headers de respuesta, y `ETag` no está entre ellos. `res.headers.get('ETag')` devuelve `null`. `Cache-Control` sí se puede leer. **No mandes `If-None-Match` a mano.** Al no ser un header safelisted, agregarlo dispara un preflight que la API rechaza — solo habilita `Content-Type`. El pedido falla entero. Es exactamente el header que el navegador manda solo sin problema: déjaselo. Ninguna de las dos aplica fuera del navegador. En Node, Deno o curl no hay CORS y tienes acceso completo. ### En un servidor o un script [Sección titulada «En un servidor o un script»](#en-un-servidor-o-un-script) Sin CORS de por medio, ahí sí administras el `ETag` tú mismo: ```js let etag = null; let cacheado = null; async function traerConEtag(url) { const res = await fetch(url, { headers: etag ? { 'If-None-Match': etag } : {}, }); if (res.status === 304) return cacheado; etag = res.headers.get('ETag'); cacheado = await res.json(); return cacheado; } ``` ## Límite de peticiones [Sección titulada «Límite de peticiones»](#límite-de-peticiones) **600 peticiones por minuto, por tienda.** El límite es agregado: lo comparten todos los visitantes de esa tienda, no es por IP ni por sesión. Al superarlo, la API responde [`429`](/docs/referencia/errores/#429--too-many-requests). En uso normal desde un tema no se alcanza: una visita hace una o dos llamadas, y la caché de borde absorbe el grueso del tráfico repetido. Si lo estás viendo, casi siempre es una llamada por sección en vez de una por página. ## Cachear del lado del tema [Sección titulada «Cachear del lado del tema»](#cachear-del-lado-del-tema) La caché HTTP resuelve las visitas repetidas. Lo que no resuelve son las llamadas duplicadas **dentro de una misma carga**: tres secciones del tema pidiendo el mismo bundle al mismo tiempo son tres requests, porque ninguna terminó todavía cuando empiezan las otras. Eso se arregla compartiendo la promesa entre secciones, que es lo que hace `traerBundle` en [el módulo cliente](/docs/guias/seccion-custom/#1-el-m%C3%B3dulo-cliente). No caches en `localStorage` sin vencimiento Si guardas el bundle en `localStorage` sin política de expiración, el comprador puede ver contenido viejo durante días después de que el comerciante lo cambió. La caché HTTP ya resuelve esto bien; agregar una capa persistente encima suele traer más problemas que beneficios. # Endpoints > Las cuatro rutas de la API pública, el vocabulario de {page} y qué devuelve cada una. Base de la API: ```plaintext https://sectionfy.codefy-apps.com ``` Todas las rutas son `GET`, no llevan headers de autenticación y responden JSON. ## Resumen [Sección titulada «Resumen»](#resumen) | Ruta | Devuelve | | ----------------------------------------------- | -------------------------------------------------------------------------------------- | | `/api/public/content/{token}/{page}` | Secciones de una página general (home, plantilla de producto, plantilla de categoría). | | `/api/public/content/{token}/{page}/{targetId}` | Secciones de un producto o categoría concretos. | | `/api/public/content/{token}/products/all` | Todos los productos con secciones configuradas. | | `/api/public/key-features-banner/{token}` | Los banners de características de la tienda. | | `/api/public/promo-modal/{token}` | Los banners promocionales de una página de inicio, categoría o producto. | | `/api/public/featured-benefits/{token}` | Las secciones de beneficios destacados de una página de inicio, categoría o producto. | | `/api/public/related-products/{token}` | Las recomendaciones de productos de cualquier página (inicio, producto, categoría, …). | | `/api/public/video-banner/{token}` | Los video banners de una página de inicio, categoría o producto. | | `/api/public/video-reviews/{token}` | Los carruseles de testimonios en video de una página de inicio, categoría o producto. | Otras rutas bajo `/api/public/` no son parte de este contrato Vas a ver rutas como `/api/public/image-carousel?store_id=…&target_id=…` o `/api/public/spec-carousel?store_id=…&target_id=…`. Las consume la app dentro de la tienda para pintar sus propias secciones: se identifican por `store_id`, no por token, y su shape puede cambiar sin aviso. **Para integrar un tema usa siempre las rutas con `{token}` de la tabla de arriba.** ## El vocabulario de `{page}` [Sección titulada «El vocabulario de {page}»](#el-vocabulario-de-page) Esta es la fuente número uno de errores `400`. **El conjunto de valores válidos para `{page}` cambia según la ruta lleve `{targetId}` o no.** | Ruta | Valores aceptados de `{page}` | | ---------------------------- | ---------------------------------------------- | | `/{token}/{page}/{targetId}` | `product` · `category` | | `/{token}/{page}` | `product-page` · `category-page` · `home-page` | La lógica detrás: los valores con `-page` son **plantillas** que aplican a todas las páginas de ese tipo; los valores sin sufijo apuntan a **una entidad concreta** y por eso exigen un `targetId`. Combinaciones válidas: ```plaintext /{token}/product/333333174 ← contenido de ese producto /{token}/category/45 ← contenido de esa categoría /{token}/home-page ← contenido de la home /{token}/product-page ← contenido común a todas las fichas de producto /{token}/category-page ← contenido común a todos los listados ``` Combinaciones que fallan, sin fallback: ```plaintext /{token}/home → 400 page must be one of: home-page, category-page, product-page /{token}/product-page/33333174 → 400 page must be one of: category, product /{token}/product → 400 (falta el targetId) ``` Una ficha de producto suele necesitar dos llamadas El contenido específico de un producto vive en `/{token}/product/{id}` y el contenido común a todas las fichas vive en `/{token}/product-page`. Si tu tema usa ambos niveles, pide los dos y únelos. Ver [recetas de fetch](/docs/guias/recetas-fetch/#combinar-plantilla-e-instancia). *** ## `GET /api/public/content/{token}/{page}` [Sección titulada «GET /api/public/content/{token}/{page}»](#get-apipubliccontenttokenpage) Secciones de una página general, sin identificador de entidad. **Parámetros** | Nombre | En | Valores | | ------- | ---- | ---------------------------------------------- | | `token` | path | Token público (`ndp_pub_…`) | | `page` | path | `home-page` · `category-page` · `product-page` | **Ejemplo** ```bash curl https://sectionfy.codefy-apps.com/api/public/content/ndp_pub_a3f9k2mX7pLqR2wN/home-page ``` ```json { "page": "home", "sections": { "grid_promocional-home-general-cmr3k81ap0002fg7dq1x8pl4c": { "titulo": "Destacados de temporada", "items": [ { "imagen": { "url": "https://cdn.example.com/promo-1.jpg", "alt": "" }, "titulo": "Hasta 30% off", "subtitulo": "Solo esta semana", "texto_boton": "Ver ofertas", "url_boton": "https://mitienda.com/ofertas", "orden": 0 } ], "estado": { "lifecycle": "active", "storefrontVisible": true }, "orden": 0 } } } ``` Dos cosas para notar: * **El campo `page` de la respuesta viene normalizado.** Pides `home-page` y recibes `"page": "home"`. El sufijo `-page` es vocabulario de la ruta, no del cuerpo. La equivalencia es `home-page → home`, `category-page → category`, `product-page → product`. * Como no hay entidad concreta, el segmento de `targetId` dentro de las claves de `sections` es el literal `general`. `product-page` y `product/{id}` comparten el valor de `page` Los dos responden `"page": "product"`. Se distinguen por el resto de la respuesta: la plantilla no trae `product_id` y sus claves llevan `general` donde la instancia lleva el ID. Si combinas ambas llamadas, no uses `page` para saber cuál es cuál. *** ## `GET /api/public/content/{token}/{page}/{targetId}` [Sección titulada «GET /api/public/content/{token}/{page}/{targetId}»](#get-apipubliccontenttokenpagetargetid) Secciones de un producto o una categoría concretos. **Parámetros** | Nombre | En | Valores | | ---------- | ---- | ----------------------------------------------- | | `token` | path | Token público (`ndp_pub_…`) | | `page` | path | `product` · `category` | | `targetId` | path | ID del producto o de la categoría en Tiendanube | **Ejemplo** ```bash curl https://sectionfy.codefy-apps.com/api/public/content/ndp_pub_a3f9k2mX7pLqR2wN/product/333333174 ``` ```json { "page": "product", "product_id": "333333174", "sections": { "faq-product-333333174-cmqic4kt5000ieq2bk3hbiat3": { "titulo": "Preguntas frecuentes", "items": [{ "pregunta": "¿Tiene garantía?", "respuesta": "12 meses.", "orden": 0 }], "estado": { "lifecycle": "active", "storefrontVisible": true }, "orden": 0 } } } ``` El campo identificador de la respuesta depende de `page`: `product_id` para `product`, `category_id` para `category`. Para una categoría, la forma es la misma y el identificador cambia a `category_id`. El segundo segmento de cada clave es `category`, así que el filtro por `-${bundle.page}-` sigue funcionando sin cambios: ```bash curl https://sectionfy.codefy-apps.com/api/public/content/ndp_pub_a3f9k2mX7pLqR2wN/category/40622990 ``` ```json { "page": "category", "category_id": "40622990", "sections": { "faq-category-40622990-cmtacpz3y0001srlqdor0qjoz": { "titulo": "Preguntas Frecuentes", "items": [ { "pregunta": "¿Hacen envíos a todo el país?", "respuesta": "Sí, sin cargo desde $50.000.", "orden": 0 }, { "pregunta": "¿Puedo cambiar un talle?", "respuesta": "Tenés 30 días para cambiarlo.", "orden": 1 } ], "estado": { "lifecycle": "active", "storefrontVisible": true }, "orden": 0, "tipo": "FAQ" } } } ``` Las secciones de la categoría y las del listado son dos llamadas Este endpoint devuelve **solo** lo que el comerciante configuró para ESA categoría. Lo que puso en “Página de categoría global” —común a todos los listados— vive en [`/{token}/category-page`](#get-apipubliccontenttokenpage) y no aparece acá. Si tu plantilla de categoría quiere mostrar las dos cosas, pedí las dos y concatená por `orden`. *** ## `GET /api/public/content/{token}/products/all` [Sección titulada «GET /api/public/content/{token}/products/all»](#get-apipubliccontenttokenproductsall) Todos los productos de la tienda que tienen al menos una sección configurada. Sirve para precargar, para armar un índice, o para páginas de listado donde quieres mostrar contenido de varios productos sin una llamada por cada uno. **Parámetros** | Nombre | En | Valores | | ------- | ---- | --------------------------- | | `token` | path | Token público (`ndp_pub_…`) | **Ejemplo** ```bash curl https://sectionfy.codefy-apps.com/api/public/content/ndp_pub_a3f9k2mX7pLqR2wN/products/all ``` A diferencia de los otros endpoints, aquí `products` es un **array**: ```json { "page": "product", "products": [ { "product_id": "333333174", "sections": { "faq-product-333333174-cmqic4kt5000ieq2bk3hbiat3": { "titulo": "Preguntas frecuentes", "items": [], "estado": { "lifecycle": "active", "storefrontVisible": true }, "orden": 0 } } }, { "product_id": "333333180", "sections": {} } ] } ``` No pagina Devuelve todos los productos con contenido en una sola respuesta. En una tienda con catálogo grande el payload puede ser considerable. Si solo necesitas un producto, usa `/{token}/product/{id}`. *** ## `GET /api/public/key-features-banner/{token}` [Sección titulada «GET /api/public/key-features-banner/{token}»](#get-apipublickey-features-bannertoken) Los banners de características de la tienda. Es un endpoint aparte porque el contenido no está atado a una página: aplica a nivel tienda. **Parámetros** | Nombre | En | Valores | | ------- | ---- | --------------------------- | | `token` | path | Token público (`ndp_pub_…`) | **Ejemplo** ```bash curl https://sectionfy.codefy-apps.com/api/public/key-features-banner/ndp_pub_a3f9k2mX7pLqR2wN ``` ```json { "sections": { "key_features_banner-home-general-cmr9p2xkd0004hs5bn3wq7t1e": { "titulo": "Por qué comprarnos", "nombre_personalizado": "Banner home", "texto": "Tres razones para elegirnos.", "imagen": null, "items": [ { "imagen": { "url": "https://cdn.example.com/envio.svg", "alt": "Envío" }, "titulo": "Envío gratis", "descripcion": "En compras superiores a $50.000", "orden": 0 } ], "estado": { "lifecycle": "active", "storefrontVisible": true }, "orden": 0 } } } ``` La respuesta es solo `{ sections }` — sin `page` ni identificador, porque no corresponde a una página. Las secciones usan las mismas claves y el mismo `estado` que el resto, así que la regla de render y el ordenamiento son idénticos. El shape completo está en [key features banner](/docs/referencia/secciones/banner-de-caracteristicas-clave/). El segmento de placement dentro de la clave refleja **dónde ubicó el banner el comerciante**, así que puede variar entre respuestas (`home`, `category`, `product`). Como esta respuesta no trae `page`, no puedes derivar el placement de la respuesta: léelo de la clave. *** ## `GET /api/public/promo-modal/{token}` [Sección titulada «GET /api/public/promo-modal/{token}»](#get-apipublicpromo-modaltoken) Los banners promocionales (imagen, título, descripción y un botón con enlace) de la página de inicio, de una categoría o de un producto. Es el mismo contenido que llega en el bundle, expuesto también como endpoint propio. **Parámetros** | Nombre | En | Valores | | ----------- | ----- | ----------------------------------------------------------------------------------------------------------- | | `token` | path | Token público (`ndp_pub_…`) | | `placement` | query | `home`, `category` o `product` (opcional; sin él devuelve todas las secciones de la tienda) | | `target_id` | query | ID de la categoría o del producto. Obligatorio con `category`/`product` (sin él `400`); con `home` da `400` | Las secciones **generales** (las que aplican a todas las categorías o a todos los productos) no se piden por acá: llegan en el bundle de `category-page` y `product-page`. **Ejemplo** ```bash curl "https://sectionfy.codefy-apps.com/api/public/promo-modal/ndp_pub_a3f9k2mX7pLqR2wN?placement=product&target_id=123" ``` ```json { "sections": { "promo_modal-product-123-cmr9p2xkd0004hs5bn3wq7t1e": { "titulo": "Envío gratis", "items": [], "campos": { "descripcion": "En compras desde $999.", "buttonText": "Ver más", "buttonUrl": "https://mi-tienda.com/promo", "imageUrl": "https://cdn.example.com/promo.webp", "imageMimeType": "image/webp" }, "estado": { "lifecycle": "active", "storefrontVisible": true }, "orden": 0 } } } ``` `nombre_personalizado` aparece solo cuando el comerciante renombró la sección; si no, la clave se **omite** (no llega como `null`). Como en los demás endpoints dedicados, la respuesta es solo `{ sections }` y el placement se lee del segundo segmento de la clave (`home`, `category` o `product`). El shape completo está en [Banner Promocional](/docs/referencia/secciones/promo-modal/). *** ## `GET /api/public/featured-benefits/{token}` [Sección titulada «GET /api/public/featured-benefits/{token}»](#get-apipublicfeatured-benefitstoken) Las secciones de beneficios destacados (tarjetas con imagen y estadística) de la página de inicio, de una categoría o de un producto. Es el mismo contenido que llega en el bundle, expuesto también como endpoint propio. **Parámetros** | Nombre | En | Valores | | ----------- | ----- | ----------------------------------------------------------------------------------------------------------- | | `token` | path | Token público (`ndp_pub_…`) | | `placement` | query | `home`, `category` o `product` (opcional; sin él devuelve todas las secciones de la tienda) | | `target_id` | query | ID de la categoría o del producto. Obligatorio con `category`/`product` (sin él `400`); con `home` da `400` | Las secciones **generales** (las que aplican a todas las categorías o a todos los productos) no se piden por acá: llegan en el bundle de `category-page` y `product-page`. **Ejemplo** ```bash curl "https://sectionfy.codefy-apps.com/api/public/featured-benefits/ndp_pub_a3f9k2mX7pLqR2wN?placement=product&target_id=344443275" ``` ```json { "sections": { "featured_benefits-product-344443275-cmu7axqfa0001uhc8b8hdznmy": { "titulo": null, "items": [ { "orden": 0, "campos": { "imageUrl": "https://cdn.example.com/serum.jpg", "cardTitle": "Glow sin maquillaje", "statValue": "86%", "statDescription": "reportó un acabado más saludable desde la primera semana." } } ], "campos": { "heading": "Por qué este serum entra en la rutina" }, "estado": { "lifecycle": "active", "storefrontVisible": true }, "orden": 0 } } } ``` Como en los demás endpoints dedicados, la respuesta es solo `{ sections }` y el placement se lee del segundo segmento de la clave (`home`, `category` o `product`). El shape completo, los campos de cada tarjeta y las trampas de este tipo están en [Beneficios Destacados](/docs/referencia/secciones/beneficios-destacados/). *** ## `GET /api/public/related-products/{token}` [Sección titulada «GET /api/public/related-products/{token}»](#get-apipublicrelated-productstoken) Las recomendaciones de productos (hasta 12 por sección, con los datos de cada producto ya resueltos) de cualquier página: inicio, carrito, búsqueda, registro, contacto, una categoría o un producto concreto. Es el mismo contenido de cada sección que llega en el bundle, expuesto también como endpoint propio. **Parámetros** | Nombre | En | Valores | | ----------- | ----- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- | | `token` | path | Token público (`ndp_pub_…`) | | `placement` | query | `home`, `category`, `product`, `cart`, `search`, `register` o `contact` (opcional; sin él devuelve las secciones de todos los placements de la tienda) | | `target_id` | query | ID de la categoría o del producto. Obligatorio con `category` y `product` (sin él `400`); con una página de tienda da `400`; si va solo, se asume `product` | Las páginas aceptadas son las que el panel ofrece para este tipo (las siete); antes solo `home` y `product`, y esas respuestas no cambian. **Ejemplo** ```bash curl "https://sectionfy.codefy-apps.com/api/public/related-products/ndp_pub_a3f9k2mX7pLqR2wN?placement=product&target_id=344443275" ``` ```json { "sections": { "related_products-product-344443275-cmu7axqfa0001uhc8b8hdznmy": { "titulo": null, "items": [ { "orden": 0, "campos": {}, "producto": { "id": "111111", "variante_id": null, "nombre": "Serum vitamina C", "variante": null, "url": "https://mi-tienda.com/productos/serum-vitamina-c/", "imagen": "https://cdn.example.com/serum-480-0.webp", "precio": 15990, "precio_comparacion": 19990, "moneda": "ARS", "disponible": true, "envio_gratis": true, "marca": "Aurora" } } ], "campos": { "heading": "Combina bien con" }, "estado": { "lifecycle": "active", "storefrontVisible": true }, "orden": 0 } } } ``` `nombre_personalizado` aparece solo cuando el comerciante renombró la sección; si no, la clave se **omite**. A diferencia del bundle, este endpoint ya filtra a las secciones activas y visibles. Las secciones generales —las que aplican a todos los productos— no se piden por acá: llegan en el bundle de `product-page`. El shape completo y las reglas de cada `producto` están en [Productos Relacionados](/docs/referencia/secciones/related-products/). Como el resto de las rutas públicas, reenvía `If-None-Match` con el `ETag` para ahorrar transferencia — ver [Cache, ETag y límites](/docs/referencia/cache/). *** ## `GET /api/public/video-banner/{token}` [Sección titulada «GET /api/public/video-banner/{token}»](#get-apipublicvideo-bannertoken) Los video banners (video de fondo con texto y botones superpuestos) de la página de inicio, de una categoría o de un producto. Es el mismo contenido que llega en el bundle, expuesto también como endpoint propio. **Parámetros** | Nombre | En | Valores | | ----------- | ----- | ----------------------------------------------------------------------------------------------------------- | | `token` | path | Token público (`ndp_pub_…`) | | `placement` | query | `home`, `category` o `product` (opcional; sin él devuelve todas las secciones de la tienda) | | `target_id` | query | ID de la categoría o del producto. Obligatorio con `category`/`product` (sin él `400`); con `home` da `400` | Las secciones **generales** (las que aplican a todas las categorías o a todos los productos) no se piden por acá: llegan en el bundle de `category-page` y `product-page`. **Ejemplo** ```bash curl "https://sectionfy.codefy-apps.com/api/public/video-banner/ndp_pub_a3f9k2mX7pLqR2wN?placement=product&target_id=344443275" ``` ```json { "sections": { "video_banner-product-344443275-cmu7axqfa0001uhc8b8hdznmy": { "titulo": "Nueva colección", "items": [ { "orden": 0, "campos": { "label": "Comprar ahora", "url": "https://mitienda.com/nueva-coleccion", "style": "PRIMARY" } } ], "campos": { "description": "Descubre lo nuevo de la temporada.", "textAlignment": "BOTTOM_RIGHT", "videoUrl": "https://cdn.example.com/hero.mp4", "videoMimeType": "video/mp4", "videoDurationSeconds": 20 }, "estado": { "lifecycle": "active", "storefrontVisible": true }, "orden": 0 } } } ``` Como en los demás endpoints dedicados, la respuesta es solo `{ sections }` y el placement se lee del segundo segmento de la clave (`home`, `category` o `product`). El shape completo, los campos de cada botón y las trampas de este tipo están en [Video Banner](/docs/referencia/secciones/video-banner/). *** ## `GET /api/public/video-reviews/{token}` [Sección titulada «GET /api/public/video-reviews/{token}»](#get-apipublicvideo-reviewstoken) Los carruseles de testimonios en video (imagen o video, nombre, texto y enlace al producto) de la página de inicio, de una categoría o de un producto. Es el mismo contenido que llega en el bundle, expuesto también como endpoint propio. **Parámetros** | Nombre | En | Valores | | ----------- | ----- | ----------------------------------------------------------------------------------------------------------- | | `token` | path | Token público (`ndp_pub_…`) | | `placement` | query | `home`, `category` o `product` (opcional; sin él devuelve todas las secciones de la tienda) | | `target_id` | query | ID de la categoría o del producto. Obligatorio con `category`/`product` (sin él `400`); con `home` da `400` | Las secciones **generales** (las que aplican a todas las categorías o a todos los productos) no se piden por acá: llegan en el bundle de `category-page` y `product-page`. **Ejemplo** ```bash curl "https://sectionfy.codefy-apps.com/api/public/video-reviews/ndp_pub_a3f9k2mX7pLqR2wN?placement=product&target_id=344443275" ``` ```json { "sections": { "video_reviews-product-344443275-cmu7axqfa0001uhc8b8hdznmy": { "titulo": "Historias reales", "items": [ { "orden": 0, "campos": { "mediaUrl": "https://cdn.example.com/ann.mp4", "mediaMimeType": "video/mp4", "mediaDurationSeconds": 42, "heading": "Ann F.", "body": "Pine Stripe Roll Bed", "productLinkUrl": "https://example.com/products/pine-stripe", "productLinkLabel": "Ver producto →" } } ], "campos": { "descripcion": "Mira lo que dicen nuestros clientes" }, "estado": { "lifecycle": "active", "storefrontVisible": true }, "orden": 0 } } } ``` Como en los demás endpoints dedicados, la respuesta es solo `{ sections }` y el placement se lee del segundo segmento de la clave (`home`, `category` o `product`). El shape completo, los campos de cada testimonio y las trampas de este tipo están en [Carrusel de testimonios en video](/docs/referencia/secciones/carrusel-de-testimonios-en-video/). *** ## Contrato máquina [Sección titulada «Contrato máquina»](#contrato-máquina) El contrato OpenAPI de estos endpoints está publicado en: ```plaintext https://sectionfy.codefy-apps.com/api/public/content/openapi.json ``` Sirve para generar clientes tipados o para alimentar herramientas que consumen OpenAPI. Es la misma información que esta referencia, en formato máquina. # Errores > Todos los códigos de estado que puede devolver la API, qué los causa y cómo reaccionar. Los errores llegan siempre con el mismo cuerpo: ```json { "statusCode": 400, "message": "token has invalid format", "error": "Bad Request" } ``` | Campo | Tipo | | | ------------ | ------ | ---------------------------------------------------------------------------- | | `statusCode` | number | Repite el código HTTP. | | `message` | string | Descripción legible. Es lo único que distingue dos errores del mismo código. | | `error` | string | Etiqueta del tipo de error. Puede no venir. | ## Códigos [Sección titulada «Códigos»](#códigos) | Código | Significa | ¿Reintentar? | | ------ | -------------------------------------------------------------------------------- | ----------------------------- | | `200` | Todo bien. Puede traer `sections` vacío. | — | | `304` | El contenido no cambió desde tu `ETag`. Sin cuerpo. | No — usa lo que ya tenías. | | `400` | Petición mal formada: token con formato inválido o `page` fuera del vocabulario. | No — hay que arreglar la URL. | | `401` | El token tiene formato válido pero no corresponde a ninguna tienda. | No. | | `403` | Origen no permitido, o el pedido fue bloqueado en el borde. | No. | | `404` | El token corresponde a una tienda que ya no está activa. | No. | | `429` | Superaste el límite de peticiones. | Sí, con espera. | | `500` | Error del servidor. | Sí, con backoff. | ## `400` — Bad Request [Sección titulada «400 — Bad Request»](#400--bad-request) Dos causas, y el `message` las distingue. **Token mal formado.** Se rechaza por forma, antes de buscar nada: ```json { "statusCode": 400, "message": "token has invalid format" } ``` También `"token is required"` si el segmento viene vacío. **`page` fuera del vocabulario.** El mensaje te dice exactamente qué se aceptaba: ```json { "statusCode": 400, "message": "page must be one of: home-page, category-page, product-page" } ``` ```json { "statusCode": 400, "message": "page must be one of: category, product" } ``` Que aparezcan dos listas distintas es la pista: el vocabulario válido depende de si la ruta lleva `{targetId}`. Está explicado en [endpoints](/docs/referencia/endpoints/#el-vocabulario-de-page). ## `401` — Unauthorized [Sección titulada «401 — Unauthorized»](#401--unauthorized) El token cumple el formato pero no existe. ```json { "statusCode": 401, "message": "Invalid token" } ``` Típicamente: token de otra tienda, token viejo después de una rotación, o un error de copiado que igual pasó la validación de forma. ## `403` — Forbidden [Sección titulada «403 — Forbidden»](#403--forbidden) El pedido no llegó por un origen aceptado, o fue frenado en el borde antes del backend. Si te ocurre desde el navegador con un token que sabes que es correcto, revisa desde dónde estás llamando. ## `404` — Not Found [Sección titulada «404 — Not Found»](#404--not-found) El token es válido y corresponde a una tienda, pero esa tienda ya no está activa: desinstaló la app o está suspendida. ```json { "statusCode": 404, "message": "Not found" } ``` Un producto sin contenido NO da 404 Pedir un `targetId` que no tiene secciones configuradas —o que no existe— devuelve `200` con `sections` vacío: ```json { "page": "product", "product_id": "999999999", "sections": {} } ``` Es deliberado: así el resultado negativo también se puede cachear. Tu código tiene que tratar `sections: {}` como “no hay nada que mostrar”, no como un error. ## `429` — Too Many Requests [Sección titulada «429 — Too Many Requests»](#429--too-many-requests) Superaste el límite de peticiones. El límite se aplica **por tienda**, no por IP: son 600 peticiones por minuto para todo el tráfico de esa tienda. En uso normal desde un tema no lo verás: una visita hace una o dos llamadas. Si aparece, casi siempre es por uno de estos dos: * Una llamada por sección en vez de una por página. Comparte la promesa — ver [recetas de fetch](/docs/guias/recetas-fetch/#una-sola-llamada-por-p%C3%A1gina). * Un fetch dentro de un loop de render o de un `useEffect` sin dependencias. ## Manejo de errores [Sección titulada «Manejo de errores»](#manejo-de-errores) La distinción que importa: **`4xx` no se reintenta, `5xx` y `429` sí**. Un `400` o un `401` fallarán igual las siguientes mil veces. ```js async function traerContenido(url, { intentos = 3 } = {}) { for (let intento = 0; intento < intentos; intento++) { const res = await fetch(url); if (res.ok) return res.json(); // Reintentable: límite de peticiones o error del servidor. if (res.status === 429 || res.status >= 500) { if (intento === intentos - 1) break; await new Promise((r) => setTimeout(r, 2 ** intento * 500)); continue; } // 400, 401, 403, 404: el reintento no cambia nada. const { message } = await res.json().catch(() => ({})); throw new Error(`Sectionfy ${res.status}: ${message ?? 'sin detalle'}`); } throw new Error('Sectionfy: se agotaron los reintentos'); } ``` ## Degradar sin romper la página [Sección titulada «Degradar sin romper la página»](#degradar-sin-romper-la-página) El contenido de esta API es un complemento de la ficha de producto, no su esqueleto. Si falla, la tienda tiene que seguir funcionando: ```js try { const bundle = await traerContenido(url); renderizar(bundle); } catch (error) { // No mostrar nada es mejor que mostrar un cartel de error a un comprador. console.warn('[sectionfy] contenido no disponible', error); } ``` Nunca dejes que un fallo de esta API bloquee el botón de compra ni deje un esqueleto de carga colgado para siempre. # Estructura de la respuesta > Cómo está armada la respuesta, cómo se lee la clave de cada sección, y la regla de render obligatoria. Todos los endpoints devuelven la misma estructura. Cambia el identificador de la página; el resto es idéntico. ```json { "page": "product", "product_id": "333333174", "sections": {} } ``` | Campo | Tipo | Presente en | | ------------- | ------------------------------- | ---------------------------------------------------------------------- | | `page` | `home` · `category` · `product` | Todas las respuestas de contenido. | | `product_id` | string | Solo cuando `page` es `product` **y** pediste un producto concreto. | | `category_id` | string | Solo cuando `page` es `category` **y** pediste una categoría concreta. | | `sections` | objeto | Siempre. Puede venir vacío (`{}`). | `sections` es un **objeto indexado por clave**, no un array. Si no hay contenido configurado, es `{}` — nunca `null`. `page` viene normalizado, no repite lo que pediste La ruta acepta `home-page`, `category-page` y `product-page`, pero la respuesta siempre contesta con la forma corta: | Pediste | `page` en la respuesta | | ---------------------------- | ---------------------- | | `/{token}/home-page` | `home` | | `/{token}/category-page` | `category` | | `/{token}/product-page` | `product` | | `/{token}/category/45` | `category` | | `/{token}/product/333333174` | `product` | Es útil que sea así: `page` coincide exactamente con el segmento de placement de las claves, lo que hace que el filtrado funcione sin traducciones. ## Anatomía de la clave [Sección titulada «Anatomía de la clave»](#anatomía-de-la-clave) Cada clave de `sections` codifica cuatro datos: ```plaintext faq_with_image-product-333333174-cmqic4kt5000ieq2bk3hbiat3 └──────┬─────┘ └──┬──┘ └───┬────┘ └────────────┬─────────┘ │ │ │ │ kind placement targetId blockId ``` | Parte | Qué es | | ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `kind` | El tipo de sección, en minúsculas y con guiones bajos: `faq`, `faq_with_image`, `image_carousel`, `rich_description`, `grid_promocional`, `call_to_action_banner`, `specs`, `specs_faq`, `key_features_banner`, `promo_modal`, `featured_benefits`, `video_banner`, `parallax_banner`, `video_reviews`. | | `placement` | La página donde vive. Solo tres valores: `home`, `category`, `product` — los mismos que `page`. | | `targetId` | El ID de la entidad, o el literal `general` cuando la sección no apunta a una entidad concreta. | | `blockId` | Identidad estable de esa instancia. Es un cuid. | ### El `blockId` es estable [Sección titulada «El blockId es estable»](#el-blockid-es-estable) No cambia cuando el comerciante reordena las secciones ni cuando borra otra. Puedes usarlo como `key` de render, como ancla de URL o como identificador de caché sin que se rompa en la próxima carga. ## Identificar el tipo de una sección [Sección titulada «Identificar el tipo de una sección»](#identificar-el-tipo-de-una-sección) Hay dos formas. Usa la primera. ### Recomendado: el campo `tipo` [Sección titulada «Recomendado: el campo tipo»](#recomendado-el-campo-tipo) Cada sección trae un discriminador explícito con el valor crudo del tipo, en mayúsculas: ```json { "tipo": "RICH_DESCRIPTION", "titulo": "Características técnicas", "items": [] } ``` Eso te deja despachar sin tocar la clave: ```js const renderers = { FAQ: renderFaq, FAQ_WITH_IMAGE: renderFaqConImagen, IMAGE_CAROUSEL: renderCarrusel, RICH_DESCRIPTION: renderDescripcion, GRID_PROMOCIONAL: renderGrilla, CALL_TO_ACTION_BANNER: renderCta, SPECS: renderSpecs, SPECS_FAQ: renderSpecsFaq, KEY_FEATURES_BANNER: renderCaracteristicas, PROMO_MODAL: renderPromo, FEATURED_BENEFITS: renderBeneficios, VIDEO_BANNER: renderVideoBanner, PARALLAX_BANNER: renderParallaxBanner, VIDEO_REVIEWS: renderTestimonios, }; for (const seccion of visiblesEnOrden(bundle)) { renderers[seccion.tipo]?.(seccion); } ``` Observa que `tipo` usa `MAYUSCULAS_CON_GUION_BAJO` mientras que el `kind` de la clave usa `minusculas_con_guion_bajo`. Son el mismo valor en dos convenciones. `tipo` es un campo aditivo Se agregó después de la primera versión del contrato, así que es opcional: una sección podría no traerlo. Si tu integración tiene que ser defensiva, usa `seccion.tipo ?? kindDesdeLaClave(key, bundle.page)`. ### Alternativa: parsear la clave [Sección titulada «Alternativa: parsear la clave»](#alternativa-parsear-la-clave) Si necesitas el tipo a partir de la clave, la única forma correcta es la comparación por **token exacto**: ```js // ❌ MAL — `faq` también matchea `faq_with_image` Object.keys(bundle.sections).filter((k) => k.startsWith('faq')); // ✅ BIEN — comparación por token exacto Object.keys(bundle.sections).filter((k) => k.split(`-${bundle.page}-`)[0] === 'faq'); ``` El separador que usas para cortar es `-{placement}-`, no un guion suelto: los propios `kind` llevan guiones bajos. Cortar por el placement completo es lo único que desambigua — y como `bundle.page` es exactamente el placement, no cuesta nada. El helper `seccionesDeTipo(bundle, kind)` que aparece en los ejemplos de toda la documentación combina las dos vías —`tipo` primero, clave como respaldo— y de paso aplica la regla de render y el orden. Está escrito una sola vez, en [el módulo cliente](/docs/guias/seccion-custom/#1-el-m%C3%B3dulo-cliente). ## Campos comunes a toda sección [Sección titulada «Campos comunes a toda sección»](#campos-comunes-a-toda-sección) Sin importar el tipo, toda sección trae estos tres: | Campo | Tipo | Significado | | ---------------------- | ---------------------------------- | ------------------------------------------------------------------------------------------------------------- | | `tipo` | string | Discriminador del tipo, en mayúsculas (`FAQ`, `RICH_DESCRIPTION`, …). | | `estado` | `{ lifecycle, storefrontVisible }` | La compuerta de render. Ver abajo. | | `orden` | number | Posición entre secciones **de todos los tipos**. Es el orden que el comerciante armó arrastrando en el panel. | | `indice` | number \| ausente | Número de instancia (1-based) dentro del mismo tipo y alcance. Solo aparece cuando hay dos o más. | | `titulo` | string \| null | Título que puso el comerciante. | | `nombre_personalizado` | string \| ausente | Nombre con el que el comerciante renombró la sección. Aparece solo si la renombró. | | `items` | array | Siempre presente. Puede estar vacío, nunca es `null`. | ### `titulo` y `nombre_personalizado` no son lo mismo [Sección titulada «titulo y nombre\_personalizado no son lo mismo»](#titulo-y-nombre_personalizado-no-son-lo-mismo) `titulo` es el encabezado que se muestra en la tienda. `nombre_personalizado` es la etiqueta interna con la que el comerciante identifica esa sección en su panel cuando tiene varias del mismo tipo. **Para renderizar usa `titulo`.** `nombre_personalizado` te sirve si estás armando una herramienta de previsualización o un selector, no para la vitrina. ### `orden` es entre tipos, no dentro del tipo [Sección titulada «orden es entre tipos, no dentro del tipo»](#orden-es-entre-tipos-no-dentro-del-tipo) Si el comerciante dejó un carrusel arriba de un FAQ, el carrusel tiene `orden` menor. Para respetar la maquetación que armó, ordena **todas** las secciones juntas por `orden` y solo entonces despacha por tipo: ```js export function visiblesEnOrden(bundle) { return Object.entries(bundle.sections) .filter(([, s]) => s.estado.lifecycle === 'active' && s.estado.storefrontVisible) .sort(([, a], [, b]) => a.orden - b.orden) .map(([key, seccion]) => ({ key, ...seccion })); } for (const seccion of visiblesEnOrden(bundle)) { renderizar(seccion.tipo, seccion); } ``` La respuesta ya viene emitida en orden ascendente de `orden`, así que en la práctica el orden de inserción del objeto coincide. Aun así **ordena explícitamente**: depender del orden de claves de un objeto JSON es frágil, y cualquier `JSON.parse` intermedio o serialización de terceros puede alterarlo. ### `indice` sirve para etiquetar duplicados [Sección titulada «indice sirve para etiquetar duplicados»](#indice-sirve-para-etiquetar-duplicados) Cuando hay un solo FAQ en un producto, `indice` no viene. Cuando hay dos, el primero trae `indice: 1` y el segundo `indice: 2`. Es lo que usas para diferenciarlos en la UI si no tienen título propio. ## La regla de render [Sección titulada «La regla de render»](#la-regla-de-render) Obligatoria ```js const visible = (s) => s.estado.lifecycle === 'active' && s.estado.storefrontVisible; ``` Aplícala antes de renderizar cualquier sección, siempre. La respuesta es un **almacén completo**: incluye a propósito secciones que el comerciante ocultó o archivó, para que existan herramientas de previsualización y para que apagar contenido no signifique perderlo. | `lifecycle` | `storefrontVisible` | ¿Renderizar? | | ----------- | ------------------- | ----------------------------- | | `active` | `true` | Sí | | `active` | `false` | No — el comerciante la ocultó | | `archived` | `true` | No — está archivada | | `archived` | `false` | No | Las secciones eliminadas de forma permanente no aparecen en la respuesta. ## Valores nulos [Sección titulada «Valores nulos»](#valores-nulos) | Campo | Puede ser `null` | Qué hacer | | ------------------ | ---------------- | ---------------------------------------------------------------------------- | | `titulo` | Sí | No renderizar el encabezado. | | `texto` | Sí | Omitir el párrafo. | | `imagen` | Sí | Omitir el ``. Solo se incluyen los assets que terminaron de procesarse. | | `imagen.alt` | Sí | Usar cadena vacía para decorativas. | | `enlace` / `boton` | Sí | No renderizar el CTA. | | `items` | **No** | Siempre array. Puede estar vacío. | Un patrón que evita casi todos los errores en runtime: ```js const src = seccion.imagen?.url; if (src) contenedor.append(crearImagen(src, seccion.imagen.alt ?? '')); ``` ## Siguiente paso [Sección titulada «Siguiente paso»](#siguiente-paso) El shape concreto de cada tipo está en [tipos de sección](/docs/referencia/secciones/). # Tipos de sección > Los tipos de sección que puede devolver la API, con la equivalencia entre el nombre que ve el comerciante y el identificador que devuelve el JSON. El comerciante arma las secciones desde un panel donde cada tipo tiene un nombre en castellano. La API las devuelve con un identificador en minúsculas y guiones bajos. **Son la misma cosa con dos nombres**, y esta tabla es la traducción. ## La tabla [Sección titulada «La tabla»](#la-tabla) | En el panel | `tipo` en el JSON | Token de la clave | Documentación | | --------------------------------- | ----------------------- | ----------------------- | -------------------------------------------------------------------- | | Preguntas Frecuentes | `FAQ` | `faq` | [Ver](/docs/referencia/secciones/preguntas-frecuentes/) | | Preguntas con imagen | `FAQ_WITH_IMAGE` | `faq_with_image` | [Ver](/docs/referencia/secciones/preguntas-con-imagen/) | | Carrusel de imágenes | `IMAGE_CAROUSEL` | `image_carousel` | [Ver](/docs/referencia/secciones/carrusel-de-imagenes/) | | Carrusel de Especificaciones | `SPECS` | `specs` | [Ver](/docs/referencia/secciones/carrusel-de-especificaciones/) | | FAQS de Especificaciones | `SPECS_FAQ` | `specs_faq` | [Ver](/docs/referencia/secciones/faqs-de-especificaciones/) | | Banner Características Técnicas | `RICH_DESCRIPTION` | `rich_description` | [Ver](/docs/referencia/secciones/banner-caracteristicas-tecnicas/) | | Grid Promocional | `GRID_PROMOCIONAL` | `grid_promocional` | [Ver](/docs/referencia/secciones/grid-promocional/) | | Call to Action Banner | `CALL_TO_ACTION_BANNER` | `call_to_action_banner` | [Ver](/docs/referencia/secciones/call-to-action-banner/) | | Banner de Características Clave | `KEY_FEATURES_BANNER` | `key_features_banner` | [Ver](/docs/referencia/secciones/banner-de-caracteristicas-clave/) | | Banner Promocional | `PROMO_MODAL` | `promo_modal` | [Ver](/docs/referencia/secciones/promo-modal/) | | Beneficios Destacados | `FEATURED_BENEFITS` | `featured_benefits` | [Ver](/docs/referencia/secciones/beneficios-destacados/) | | Productos relacionados | `RELATED_PRODUCTS` | `related_products` | [Ver](/docs/referencia/secciones/related-products/) | | Video Banner | `VIDEO_BANNER` | `video_banner` | [Ver](/docs/referencia/secciones/video-banner/) | | Parallax Banner | `PARALLAX_BANNER` | `parallax_banner` | [Ver](/docs/referencia/secciones/parallax-banner/) | | Banner de Garantía | `WARRANTY_BANNER` | `warranty_banner` | [Ver](/docs/referencia/secciones/banner-de-garantia/) | | Preguntas frecuentes del producto | `PRODUCT_FAQS` | `product_faqs` | [Ver](/docs/referencia/secciones/preguntas-frecuentes-del-producto/) | | Carrusel de testimonios en video | `VIDEO_REVIEWS` | `video_reviews` | [Ver](/docs/referencia/secciones/carrusel-de-testimonios-en-video/) | Las dos columnas del medio son el mismo identificador en dos convenciones: `MAYUSCULAS_CON_GUION_BAJO` en el campo `tipo`, `minusculas_con_guion_bajo` en la clave de `sections`. Para despachar por tipo usa `tipo` — el detalle está en [la estructura de la respuesta](/docs/referencia/estructura-respuesta/#identificar-el-tipo-de-una-secci%C3%B3n). Por qué te hablarán en los dos idiomas Cuando un comerciante te diga “agrégame el Banner Características Técnicas”, en el JSON buscarás `RICH_DESCRIPTION`. Esta tabla es la que traduce esa conversación. Los nombres del panel también pueden cambiar sin que cambie el identificador: **el contrato lo fija el `tipo`, no la etiqueta**. ## Lo que comparten todos [Sección titulada «Lo que comparten todos»](#lo-que-comparten-todos) Sin importar el tipo, toda sección trae los mismos campos comunes —`tipo`, `titulo`, `items`, `estado`, `orden`, `indice`— y se rige por las mismas dos reglas: 1. **La regla de render.** Muéstrala solo si `estado.lifecycle === 'active' && estado.storefrontVisible === true`. 2. **El orden.** Ordénala por `orden` entre secciones, y los `items` por su propio `orden`. Las dos están explicadas una sola vez en [la estructura de la respuesta](/docs/referencia/estructura-respuesta/). Las páginas de aquí abajo no las repiten: se concentran en lo que cada tipo tiene de particular. ## Cómo leer estas páginas [Sección titulada «Cómo leer estas páginas»](#cómo-leer-estas-páginas) Cada página tiene la misma estructura: * **Shape** — los campos, marcando qué puede ser `null` y qué puede faltar. * **Ejemplo** — un JSON real, no inventado. * **Renderizar** — solo la parte específica del tipo. El fetch, el filtro y el escape de texto son compartidos y viven en [conectar una sección custom](/docs/guias/seccion-custom/#1-el-m%C3%B3dulo-cliente). * **Notas** — las trampas propias de ese tipo. Los ejemplos asumen dos helpers de ese módulo compartido: `seccionesDeTipo(bundle, kind)`, que ya aplica la regla de render y el orden, y `escapar(texto)`, que evita que el contenido del comerciante se inyecte como HTML. # Banner Características Técnicas > Tarjeta compuesta con imagen, texto, lista de características y botones de acción — shape, ejemplo JSON y cómo renderizarla. El tipo más completo: imagen principal, párrafo, lista de características con íconos, y botones de acción configurables. | En el panel | `tipo` en el JSON | Token de la clave | | ----------------------------------- | ------------------ | ------------------ | | **Banner Características Técnicas** | `RICH_DESCRIPTION` | `rich_description` | El nombre del panel y el de la API no se parecen El identificador `rich_description` es anterior al nombre que hoy ve el comerciante. Si alguien te pide el “Banner Características Técnicas”, en el JSON buscas `RICH_DESCRIPTION`. ## Shape [Sección titulada «Shape»](#shape) ```ts { tipo: 'RICH_DESCRIPTION'; titulo: string | null; texto: string | null; // párrafo principal imagen: { url: string; alt: string | null } | null; // imagen hero items: Array<{ // lista de características texto: string; icono: string | null; // URL suelta, no objeto orden: number; }>; acciones?: Array; // opcional — ver abajo } ``` `items[].icono` es una URL suelta En este tipo el ícono de cada característica es un **string**, no un objeto `{ url, alt }` como en el resto de las secciones. Es la excepción del contrato. ### Las acciones [Sección titulada «Las acciones»](#las-acciones) ```ts type Accion = { tipo: 'guia_tallas' | 'manual' | 'manual-uso'; modo: 'image' | 'link' | 'pdf'; etiqueta: string | null; // Presente según `modo`: imagen?: { url: string; alt: string | null }; // modo === 'image' enlace?: { // modo === 'link' url: string; etiqueta: string | null; nueva_pestana: boolean; }; pdf?: { url: string }; // modo === 'pdf' }; ``` `modo` dice **cómo entregar** el recurso y qué campo leer. `tipo` dice **qué es**, para poner una etiqueta por defecto cuando `etiqueta` es `null`. | `modo` | Campo presente | Render sugerido | | ------- | -------------- | ----------------------------- | | `image` | `imagen` | Abrir en modal o lightbox. | | `link` | `enlace` | Un `` común. | | `pdf` | `pdf` | Un `` que abre o descarga. | ## Ejemplo [Sección titulada «Ejemplo»](#ejemplo) ```json { "rich_description-product-333333174-cmqic8fcm000ueq2b5gm7zg8q": { "tipo": "RICH_DESCRIPTION", "titulo": "Características técnicas", "texto": "Diseñada para uso diario intensivo, con materiales de grado industrial.", "imagen": { "url": "https://cdn.example.com/hero.jpg", "alt": "Producto sobre mesa de trabajo" }, "items": [ { "texto": "Resistente al agua IPX7", "icono": "https://cdn.example.com/agua.svg", "orden": 0 }, { "texto": "Batería de 18 horas", "icono": null, "orden": 1 } ], "acciones": [ { "tipo": "guia_tallas", "modo": "image", "imagen": { "url": "https://cdn.example.com/tallas.png", "alt": null }, "etiqueta": null }, { "tipo": "manual", "modo": "pdf", "pdf": { "url": "https://cdn.example.com/manual.pdf" }, "etiqueta": "Descargar manual" } ], "estado": { "lifecycle": "active", "storefrontVisible": true }, "orden": 1 } } ``` ## Renderizar [Sección titulada «Renderizar»](#renderizar) Lo específico del tipo es el despacho de las acciones: ```js // Etiquetas por defecto cuando el comerciante no puso una. const ETIQUETAS = { guia_tallas: 'Guía de tallas', manual: 'Manual', 'manual-uso': 'Manual de uso', }; function renderAccion(accion) { const texto = escapar(accion.etiqueta ?? ETIQUETAS[accion.tipo] ?? 'Ver más'); switch (accion.modo) { case 'image': // No es un enlace: abre un modal con la imagen. return ``; case 'link': return ` ${escapar(accion.enlace.etiqueta ?? accion.etiqueta ?? ETIQUETAS[accion.tipo])} `; case 'pdf': return `${texto}`; default: // Modo desconocido: no romper, simplemente no renderizar. return ''; } } ``` Y la lista de características, donde el ícono es decorativo porque el texto de al lado ya dice lo mismo: ```js function renderCaracteristica(item) { return `
  • ${item.icono ? `` : ''} ${escapar(item.texto)}
  • `; } ``` ## Notas [Sección titulada «Notas»](#notas) * **`acciones` puede no venir.** Es opcional: usa `seccion.acciones ?? []`. * **Despacha por `modo`, no por `tipo`.** Leer `accion.enlace` cuando `modo` es `"image"` da `undefined`. * Incluye un caso `default` en el switch: si mañana aparece un `modo` nuevo, tu tema lo ignora en vez de romperse. # Banner de Características Clave > Beneficios de la tienda con ícono — el único tipo con endpoint propio. Beneficios a nivel tienda con ícono, del estilo “envío gratis / cuotas sin interés / cambios sin cargo”. | En el panel | `tipo` en el JSON | Token de la clave | | ----------------------------------- | --------------------- | --------------------- | | **Banner de Características Clave** | `KEY_FEATURES_BANNER` | `key_features_banner` | Tiene endpoint propio Este tipo **no llega en el bundle de contenido**. Se pide aparte: ```bash curl https://sectionfy.codefy-apps.com/api/public/key-features-banner/TU_TOKEN ``` Si lo buscas en la respuesta de `/api/public/content/...`, no está. ## La respuesta [Sección titulada «La respuesta»](#la-respuesta) La respuesta es más simple que la del bundle: solo `sections`, sin `page` ni identificador de entidad. ```ts { sections: Record; } ``` Como no hay `page`, **no puedes derivar el placement de la respuesta**. Si necesitas saber dónde ubicó el comerciante cada banner, léelo del segundo segmento de la clave. ## Shape de la sección [Sección titulada «Shape de la sección»](#shape-de-la-sección) ```ts { titulo: string | null; texto: string | null; imagen: { url: string; alt: string | null } | null; // imagen de la sección items: Array<{ imagen: { url: string; alt: string | null } | null; titulo: string | null; // nullable, a diferencia de SPECS descripcion: string | null; // nullable, a diferencia de SPECS orden: number; }>; } ``` Los items son casi idénticos a los de [Carrusel de Especificaciones](/docs/referencia/secciones/carrusel-de-especificaciones/), con una diferencia que importa: aquí **`titulo` y `descripcion` pueden ser `null`**. El panel permite guardar una característica con el encabezado o el cuerpo vacío, y esos casos llegan como `null` en vez de omitirse. ## Ejemplo [Sección titulada «Ejemplo»](#ejemplo) ```json { "sections": { "key_features_banner-home-general-cmr9p2xkd0004hs5bn3wq7t1e": { "titulo": "Por qué comprarnos", "nombre_personalizado": "Banner home", "texto": "Tres razones para elegirnos.", "imagen": null, "items": [ { "imagen": { "url": "https://cdn.example.com/envio.svg", "alt": "Envío" }, "titulo": "Envío gratis", "descripcion": "En compras superiores a $50.000", "orden": 0 }, { "imagen": { "url": "https://cdn.example.com/cuotas.svg", "alt": null }, "titulo": "12 cuotas sin interés", "descripcion": null, "orden": 1 } ], "estado": { "lifecycle": "active", "storefrontVisible": true }, "orden": 0 } } } ``` ## Renderizar [Sección titulada «Renderizar»](#renderizar) La respuesta no trae `page`, así que el helper `seccionesDeTipo` no aplica tal cual. Filtra directo sobre `sections`, con la misma regla de render: ```js const res = await fetch( `https://sectionfy.codefy-apps.com/api/public/key-features-banner/${TOKEN}`, ); const { sections } = await res.json(); const banners = Object.entries(sections) .filter(([, s]) => s.estado.lifecycle === 'active' && s.estado.storefrontVisible) .sort(([, a], [, b]) => a.orden - b.orden); for (const [key, banner] of banners) { const caracteristicas = banner.items .slice() .sort((a, b) => a.orden - b.orden) // Una característica sin título ni descripción no aporta nada: omitirla. .filter((item) => item.titulo || item.descripcion) .map( (item) => `
  • ${ item.imagen ? `${escapar(item.imagen.alt ?? '')}` : '' } ${item.titulo ? `

    ${escapar(item.titulo)}

    ` : ''} ${item.descripcion ? `

    ${escapar(item.descripcion)}

    ` : ''}
  • `, ) .join(''); contenedor.insertAdjacentHTML( 'beforeend', `
    ${banner.titulo ? `

    ${escapar(banner.titulo)}

    ` : ''} ${banner.texto ? `

    ${escapar(banner.texto)}

    ` : ''}
      ${caracteristicas}
    `, ); } ``` ## Notas [Sección titulada «Notas»](#notas) * **Filtra los items vacíos.** Un item con `titulo` y `descripcion` en `null` renderiza una tarjeta en blanco. * Es una llamada más, aparte del bundle. Si tu página necesita las dos cosas, dispáralas en paralelo — ver [recetas de fetch](/docs/guias/recetas-fetch/#dos-endpoints-en-paralelo). # Banner de Garantía > Banner de producto con sello de garantía, imagen o video y botón — shape, ejemplo JSON y cómo renderizarlo. Banner de garantía de producto: título, descripción, una imagen o video y un botón opcional, más un sello circular con el texto de garantía. El comerciante lo arma una vez y lo ubica en la **home**, en **todas** las categorías o productos, o en una página de producto puntual. | En el panel | `tipo` en el JSON | Token de la clave | | ---------------------- | ----------------- | ----------------- | | **Banner de Garantía** | `WARRANTY_BANNER` | `warranty_banner` | ## Shape de la sección [Sección titulada «Shape de la sección»](#shape-de-la-sección) Este tipo usa el sobre genérico de los tipos generados: los campos editables viajan dentro de `campos`, no en propiedades con nombre propio. ```ts { tipo: 'WARRANTY_BANNER'; // identificador del tipo en el bundle titulo: string | null; // columna compartida; hoy puede venir null nombre_personalizado?: string; // nombre interno del panel (opcional) items: []; // siempre vacío: el contenido vive a nivel sección campos: { productTitle: string | null; productDescription: string | null; productImageUrl: string | null; // imagen o video; null si no hay archivo o no está READY productImageMimeType: string | null; // "image/webp", "video/mp4", …; null sin archivo productImageDurationSeconds?: number; // solo videos READY con duración registrada; si no, la clave no viene warrantyButtonUrl: string | null; warrantyButtonLabel: string | null; warrantySealText: string | null; }; estado: { lifecycle: string; storefrontVisible: boolean }; orden: number; indice?: number; // solo cuando hay más de una sección del mismo tipo } ``` `items` viene como array vacío, por consistencia con el resto de las secciones. Este tipo no lo usa: no lo recorras. ## Ejemplo [Sección titulada «Ejemplo»](#ejemplo) ```json { "sections": { "warranty_banner-product-344443275-cmu7axqfa0001uhc8b8hdznmy": { "tipo": "WARRANTY_BANNER", "titulo": null, "items": [], "campos": { "productTitle": "Botas de presoterapia PRO 3", "productDescription": "Botas de presoterapia con 5 años de garantía oficial.", "productImageUrl": "https://cdn.example.com/botas-pro-3.jpg", "productImageMimeType": "image/jpeg", "warrantyButtonUrl": "https://mitienda.com/productos/botas-pro-3/", "warrantyButtonLabel": "Ver producto", "warrantySealText": "5 años de garantía" }, "estado": { "lifecycle": "active", "storefrontVisible": true }, "orden": 0 } } } ``` ## Renderizar [Sección titulada «Renderizar»](#renderizar) Todos los `campos` pueden ser `null`, así que cada parte es condicional: ```js const res = await fetch( `https://sectionfy.codefy-apps.com/api/public/content/${TOKEN}/product/${PRODUCT_ID}`, ); const { sections } = await res.json(); const garantias = Object.entries(sections) .filter(([, s]) => s.tipo === 'WARRANTY_BANNER') .filter(([, s]) => s.estado.lifecycle === 'active' && s.estado.storefrontVisible) .sort(([, a], [, b]) => a.orden - b.orden); for (const [key, seccion] of garantias) { const { campos } = seccion; contenedor.insertAdjacentHTML( 'beforeend', `
    ${ !campos.productImageUrl ? '' : campos.productImageMimeType?.startsWith('video/') ? `` : `${escapar(campos.productTitle ?? '')}` }
    ${campos.productTitle ? `

    ${escapar(campos.productTitle)}

    ` : ''} ${campos.productDescription ? `

    ${escapar(campos.productDescription)}

    ` : ''} ${ campos.warrantyButtonUrl ? ` ${escapar(campos.warrantyButtonLabel ?? 'Ver producto')} ` : '' }
    ${ campos.warrantySealText ? `
    ${escapar(campos.warrantySealText)}
    ` : '' }
    `, ); } ``` ## Notas [Sección titulada «Notas»](#notas) * **El sello es siempre circular.** El comerciante solo carga el texto (`warrantySealText`); el círculo, el borde y la tipografía son decisión de tu tema. * **La imagen puede ser un video.** Decidí `` o `