skill · sumi-shopify · v0.6.1
JavaScript de la tienda en Shopify
JavaScript de la tienda para temas de Shopify: JS puro y Web Components, mejora progresiva sobre los formularios de Liquid, Section Rendering API, Cart AJAX API (add, change, update, renderizado agrupado de sections para el cajón del carrito y los contadores), búsqueda predictiva, selección de variantes con sincronización de URL y medios, eventos del editor del tema, debounce y gestión de condiciones de carrera, anuncios con aria-live y carga de scripts sin bloqueo. Úsala al escribir o revisar assets/*.js, src/**/*.{js,ts}, custom elements, cajón del carrito, quick add, selectores de cantidad, facetas/filtros, búsqueda predictiva, selectores de variante, o cuando una section deba actualizarse sin recargar la página completa.
- Referencia
- sumi-shopify
- skill
- shopify-storefront-js
- source
- plugins/sumi-shopify/skills/shopify-storefront-js/SKILL.md
Usa por defecto Liquid renderizado en el servidor más pequeños Web Components que lo mejoren. Liquid renderiza el HTML; el JS le pide a Shopify HTML nuevo (Section Rendering API) en lugar de reconstruir el marcado a partir de JSON. Estándares generales de JS/TS: sumi:js-ts.
Principios
- JS puro, módulos ES, sin jQuery, sin runtime de framework en la tienda salvo que el proyecto ya incluya uno. Una biblioteca de carruseles o de diálogos es aceptable solo si el proyecto ya la usa; prefiere
<dialog>,<details>y CSS scroll-snap nativos. - Mejora progresiva: el formulario de producto es un
{% form 'product' %}real que envía sin JS; la página del carrito funciona sin el cajón; los filtros son un formulario GET. - Un custom element por comportamiento, acotado a su subárbol. Sin singletons globales que alcancen el DOM por nombre de clase.
- Carga con
<script src="{{ 'x.js' | asset_url }}" defer></script>otype="module". Nuncascript_tag(sin defer), nunca<script>en línea dentro de bucles ni por instancia de block. - URLs desde
window.Shopify.routes.root(o los objetos Liquidroutes.*pasados mediante atributos data) para que funcionen las rutas multi-idioma/mercado. - Los textos vienen de Liquid (atributos
data-*o un único blob JSON renderizado con| ty| json), nunca texto en inglés fijo en el JS.
Patrón de Web Component
class QuantityInput extends HTMLElement {
#input; #abort;
connectedCallback() {
this.#input = this.querySelector('input[type="number"]');
this.#abort = new AbortController();
this.addEventListener('click', this.#onClick, { signal: this.#abort.signal });
}
disconnectedCallback() { this.#abort.abort(); }
#onClick = (event) => {
const button = event.target.closest('button[name]');
if (!button) return;
button.name === 'plus' ? this.#input.stepUp() : this.#input.stepDown();
this.#input.dispatchEvent(new Event('change', { bubbles: true }));
};
}
if (!customElements.get('quantity-input')) customElements.define('quantity-input', QuantityInput);
Protege customElements.define (las sections se vuelven a renderizar en el editor y los scripts pueden cargarse dos veces). Limpia los listeners en disconnectedCallback; las sections se reemplazan por completo.
Section Rendering API
async function fetchSection(sectionId, url = window.location.pathname) {
const target = new URL(url, window.location.origin);
target.searchParams.set('section_id', sectionId);
const res = await fetch(target, { headers: { Accept: 'text/html' } });
if (!res.ok) throw new Error(`Section ${sectionId}: ${res.status}`);
return new DOMParser().parseFromString(await res.text(), 'text/html');
}
?section_id=xdevuelve el HTML de una section;?sections=a,bdevuelve JSON{ id: html }(número de sections por petición limitado; verifica, históricamente 5).- Usa el id de ejecución de la section (
section.id, expuesto mediantedata-section-id) para las sections de template; las sections de grupo también tienen ids generados. - Reemplaza el subárbol estable más pequeño, conserva el foco (guarda el identificador de
document.activeElementy restáuralo) y anuncia el cambio.
Cart AJAX API
const root = window.Shopify.routes.root;
async function addToCart(formData, sectionIds = []) {
if (sectionIds.length) formData.append('sections', sectionIds.join(','));
formData.append('sections_url', window.location.pathname);
const res = await fetch(`${root}cart/add.js`, { method: 'POST', body: formData, headers: { Accept: 'application/json' } });
const data = await res.json();
if (!res.ok) throw Object.assign(new Error(data.description || data.message), { status: res.status, data });
return data; // line item(s) + data.sections when requested
}
- Endpoints:
cart.js(GET),cart/add.js,cart/change.js(id= clave de la línea,quantity),cart/update.js(updates,note,attributes),cart/clear.js. Usa lakeyde la línea, no el id de la variante, para los cambios (la misma variante puede aparecer en varias líneas con distintas propiedades o planes de venta). - Solicita el renderizado agrupado de sections (
sections) para refrescar el cajón, la burbuja del icono del carrito y la barra de envío gratis en el mismo viaje de ida y vuelta, en lugar de un segundo fetch. - Los errores 422 (agotado, regla de cantidad, inventario) llevan un mensaje legible en
description: muéstralo junto al control y anúncialo. - Dispara un evento personalizado documentado tras el éxito (
document.dispatchEvent(new CustomEvent('cart:updated', { detail: { cart } }))) para que otros componentes y apps puedan reaccionar. Comprueba lo que el tema ya despacha antes de inventar nombres.
Selección de variantes
- Los inputs de opción son grupos de radios en fieldsets (consulta
shopify-performance-a11y). Al cambiar, resuelve la variante, actualiza el input ocultoid, hazhistory.replaceStatede la URL?variant=y refresca el precio, la disponibilidad, los medios y los botones de compra. - Prefiere volver a renderizar mediante la Section Rendering API con
?variant=<id>(o valores de opción, verifica el soporte actual de URL de listados combinados/valores de opción) en lugar de enviar todas las variantes como JSON en productos con muchas variantes. - Nunca confíes solo en la disponibilidad del lado del cliente; la API del carrito es la fuente de verdad.
Búsqueda predictiva
GET {root}search/suggest?q=...§ion_id=predictive-searchdevuelve HTML renderizado en el servidor usando el objetopredictive_search;search/suggest.jsondevuelve JSON. Prefiere la variante de section por paridad de marcado y de traducciones.- Aplica debounce a la entrada (unos 250 a 300 ms), aborta las peticiones obsoletas con
AbortController, ignora las respuestas de consultas desactualizadas, cachea por consulta. - Semántica de combobox: input
role="combobox",aria-expanded,aria-controls,aria-activedescendant; resultadosrole="listbox"conrole="option"; teclas de flecha, Enter, Escape. Anuncia el número de resultados mediante una región en vivo cortés. Detalles:references/ajax-apis.md.
Corrección asíncrona
- Aplica debounce a la escritura de la cantidad; deshabilita o marca como ocupados (
aria-busy="true") los controles durante una petición; serializa las mutaciones del carrito (cola) para que los clics rápidos no compitan entre sí. - Gestiona siempre
!res.oky los fallos de red con un mensaje visible y traducido. Ningúncatch {}silencioso. - Aborta las peticiones en curso al navegar o al volver a renderizar.
Compatibilidad con el editor del tema
Escucha en document los eventos shopify:section:load, shopify:section:unload, shopify:section:select, shopify:section:deselect, shopify:section:reorder, shopify:block:select, shopify:block:deselect, shopify:inspector:activate/deactivate. Los custom elements suelen reinicializarse solos al cargar; usa block:select para abrir la diapositiva, la pestaña o el cajón que contiene el block seleccionado. Comprueba window.Shopify.designMode para el comportamiento exclusivo del editor.
Señales de relleno de IA
- jQuery (
$(...),$.ajax), o un framework montado solo para alternar una clase. - Reconstruir el HTML de las tarjetas de producto o de las líneas del carrito en plantillas literales de JS, con texto en inglés y formato de moneda fijos, en lugar de Section Rendering.
fetch('/cart/add.js')con una raíz fija, sin gestión de errores, sin cabeceraAccept, con el id de variante usado parachange.js.innerHTMLcon datos de la API sin escapar (XSS) ostyled_texttratado como seguro sin motivo.- Sondeo con
setTimeoutpara el carrito, envoltoriosDOMContentLoadedque se rompen tras recargar sections, bolsas de estado globaleswindow.myTheme = {...}. - Listeners añadidos en
connectedCallbacksin eliminarlos;customElements.definesin protección. onclick=""en línea en Liquid;<script>dentro de un{% for %}.
Lista de verificación
- Funciona sin JS en la línea base (los formularios envían, los enlaces navegan); el JS solo mejora.
- Scripts con defer o módulos; sin scripts bloqueantes en
<head>; sin cargas duplicadas de bibliotecas. - Rutas mediante
Shopify.routes.root; textos traducidos desde Liquid; precios formateados en el servidor. - Las mutaciones del carrito usan claves de línea,
sectionsagrupadas, estados de error, estados de ocupado y peticiones serializadas. - Las actualizaciones del DOM conservan el foco y anuncian los resultados mediante
aria-live. - Se reinicializa en
shopify:section:load; se limpia al desconectarse; sin errores de consola en el editor. - Sin tareas largas en la interacción (comprueba el INP); consulta
sumi:performance.
Relacionado: shopify-performance-a11y, shopify-liquid, sumi:js-ts.
APIs AJAX de la tienda: patrones
Los endpoints son relativos a window.Shopify.routes.root (termina en /). Las formas de las respuestas están resumidas; verifica los campos en shopify.dev antes de depender de uno poco usado.
Resumen de la Cart API
| Endpoint | Método | Cuerpo | Devuelve |
|---|---|---|---|
cart.js |
GET | ninguno | JSON del carrito (items, item_count, total_price, currency, token, ...) |
cart/add.js |
POST | FormData del formulario de producto, o JSON { items: [{ id, quantity, properties, selling_plan }] } |
Línea(s) añadida(s); clave sections si se solicita |
cart/change.js |
POST | { id: lineKey, quantity } (o line, índice desde 1) |
Carrito completo; sections si se solicita |
cart/update.js |
POST | { updates: { [variantIdOrKey]: qty }, note, attributes } |
Carrito completo |
cart/clear.js |
POST | ninguno | Carrito vacío |
Parámetros comunes en las llamadas que mutan: sections (lista separada por comas de ids de section) y sections_url (contexto de página para el renderizado).
Gestión de errores: una respuesta no 2xx devuelve JSON con status, message, description. 422 es una regla de negocio (agotado, cantidad máxima, plan de venta obligatorio). Muestra description junto al control.
Flujo del cajón del carrito
- Envío del formulario de producto:
preventDefault, ponaria-busy="true"yaria-disabled="true"en el botón, mantén visible la etiqueta. cart/add.jsconsections: cart-drawer,cart-icon-bubble(usa los ids de section reales del tema).- En caso de éxito: cambia el HTML interno del cajón desde
data.sections['cart-drawer'], actualiza la burbuja, abre el cajón (dialog.showModal()), mueve el foco al encabezado del cajón o al botón de cierre, anuncia "Added to cart" mediante la región en vivo. - En caso de error: restaura el botón, renderiza el mensaje en una región
role="alert"o cortés ligada al formulario (aria-describedby). - Al cerrar: devuelve el foco al disparador.
Cambios de cantidad en el cajón
let queue = Promise.resolve();
function changeLine(key, quantity, sectionIds) {
queue = queue.then(async () => {
const res = await fetch(`${Shopify.routes.root}cart/change.js`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify({ id: key, quantity, sections: sectionIds, sections_url: location.pathname }),
});
const data = await res.json();
if (!res.ok) throw new Error(data.description || data.message);
return data;
});
return queue;
}
Aplica debounce a la entrada tecleada (300 ms); aplica en change, no en cada pulsación de tecla. Tras el renderizado, restaura el foco en el input de la misma línea (haz la correspondencia por data-line-key, ya que los índices de línea cambian). Anuncia el nuevo subtotal de forma cortés.
Section Rendering API
GET /path?section_id=<id>: HTML sin procesar de una section, renderizada en el contexto de esa ruta (producto, colección, términos de búsqueda, parámetros de consulta como filtros ypage).GET /path?sections=<id1>,<id2>: mapa JSON de id a HTML. Número limitado de ids por llamada (verifica).- Funciona para las sections de template y las sections de grupo. Las sections estáticas renderizadas con
{% section %}usan el nombre de su archivo como id. section.indexes nil en este contexto: las decisiones lazy/eager basadas en el índice deben tener una alternativa.
Patrón de filtrado por facetas: los filtros son un <form> GET; al cambiar, serializa con new URLSearchParams(new FormData(form)), history.pushState, haz fetch de ?section_id=<main-collection-id>&<params>, reemplaza la cuadrícula de productos y el panel de facetas, actualiza el recuento de resultados en una región en vivo, mantén el foco en el control cambiado. Gestiona popstate para volver a renderizar al ir hacia atrás o adelante.
Búsqueda predictiva
Petición:
GET {root}search/suggest?q=<term>&resources[type]=product,collection,query&resources[limit]=6&resources[limit_scope]=each§ion_id=predictive-search
- Parámetros:
q(obligatorio),resources[type](product,collection,page,article,query),resources[limit](1 a 10),resources[limit_scope](alloeach),resources[options][unavailable_products](show,hide,last),resources[options][fields](title,product_type,variants.title,variants.sku,vendor,tag,body). - Variante JSON:
search/suggest.jsondevuelveresources.results.{products,collections,pages,articles,queries}.queries[].styled_textcontiene marcado<mark>; lo genera Shopify, pero aun así renderiza los demás campos mediantetextContent. - Evita buscar en
bodyen tiendas multi-idioma salvo que lo hayas verificado; puede mostrar coincidencias de idiomas mezclados.
Contrato de interacción del combobox (combobox de WAI-ARIA APG con popup listbox):
- Input:
role="combobox",aria-autocomplete="list",aria-expanded,aria-controls="<listbox id>",aria-activedescendantapuntando al id de la opción resaltada. - Abajo/Arriba mueven el resaltado, Enter sigue el enlace resaltado o envía el formulario de búsqueda, Escape cierra y luego borra.
- Región en vivo: "6 results" (traducido, con plural) tras cada respuesta estabilizada; no anuncies cada pulsación de tecla.
- Mantén un
<form action="{{ routes.search_url }}">real para que Enter sin JS siga funcionando.
Recomendaciones de producto
GET {root}recommendations/products?product_id=<id>&limit=4§ion_id=<id>&intent=related|complementary devuelve el HTML de la section renderizada con el objeto recommendations. Carga al intersectar (IntersectionObserver), reserva espacio para evitar el CLS, oculta la section si recommendations.performed? and recommendations.products_count == 0.
Eventos e integración
- Comprueba lo que el tema ya emite (p. ej. pub/sub al estilo Dawn, o eventos personalizados
cart:updated) y reutilízalo. - Las apps suelen escuchar los cambios del carrito o parchear
fetch; mantén tus peticiones estándar para que sigan funcionando. - No interceptes la navegación al checkout; el checkout queda fuera del control del tema.