Saltar al contenido

Código a la vista

Lo que escribo para un cliente es suyo y vive en su repositorio privado. Esto es lo que sí puedo enseñar: dos repositorios públicos que puedes clonar y ejecutar, tres patrones que repito en casi todos los proyectos B2B y las decisiones técnicas de esta misma web, con sus números.

Repositorios públicos

JavaScript · Shopify Functions

shopify-checkout-lab

La Shopify Function que se ejecuta en el Checkout Lab de la portada: el mismo archivo que se despliega en una tienda, con su consulta de entrada y sus tests. En el Lab puedes cambiarle la configuración y ver qué deja de pasar.

Astro · JavaScript

portfolio

Esta web. Dos idiomas sin librería de i18n, imágenes AVIF y WebP generadas con sharp antes del build, y un esquema de contenido que rompe la compilación si a un proyecto le falta una traducción o una captura.

Tres patrones B2B

Ninguno es código de cliente: están reescritos desde cero para poder publicarlos. El problema que resuelven sí es real y se repite en casi todos los proyectos mayoristas.

Cajas completas en el checkout

El problema

El mayorista pide 7 unidades de algo que sale del almacén en cajas de 6. O alguien abre una caja, o se arregla por teléfono dos días después. Una Function de validación lo corta en el checkout y dice exactamente qué cambiar.

La trampa

El target también se ejecuta mientras se edita el carrito: sin la salida temprana, el comprador no puede teclear 7 de camino a 12. Y hay presupuesto —11 millones de instrucciones y 128 kB de entrada—, así que la consulta pide solo los cuatro campos que la función mira.

src/cart_validations_generate_run.js
// Wholesale case packs. A buyer who orders 7 units of something that leaves the
// warehouse in boxes of 6 forces somebody to open a box, and that is found out
// after the order is paid. This blocks checkout instead, and says what to change.
//
// The case pack lives in a variant metafield (b2b.case_pack, integer). A variant
// without it is not sold by the box and is left alone.
//
// Target: cart.validations.generate.run (Cart and Checkout Validation API)

// @ts-check
/**
 * @typedef {import("../generated/api").CartValidationsGenerateRunInput} RunInput
 * @typedef {import("../generated/api").CartValidationsGenerateRunResult} RunResult
 * @typedef {import("../generated/api").ValidationError} ValidationError
 */

// Five lines is already a long list to read in a checkout banner.
const MAX_ERRORS = 5;

/**
 * @param {RunInput} input
 * @returns {RunResult}
 */
export function cartValidationsGenerateRun(input) {
  // The target also runs while the cart is being edited. Blocking there means the
  // buyer cannot type 7 on the way to 12, so only the checkout steps are checked.
  const { step } = input.buyerJourney;
  if (step !== 'CHECKOUT_INTERACTION' && step !== 'CHECKOUT_COMPLETION') {
    return { operations: [] };
  }

  /** @type {ValidationError[]} */
  const errors = [];

  for (const line of input.cart.lines) {
    if (line.merchandise.__typename !== 'ProductVariant') continue;

    const pack = Number.parseInt(line.merchandise.casePack?.value ?? '', 10);
    if (!Number.isInteger(pack) || pack < 2) continue;

    const remainder = line.quantity % pack;
    if (remainder === 0) continue;

    // Round up, never down: nobody wants to be told to buy less.
    const suggestion = line.quantity + (pack - remainder);
    errors.push({
      message: `${line.merchandise.title} ships in boxes of ${pack}. Change ${line.quantity} to ${suggestion}.`,
      target: '$.cart',
    });

    if (errors.length === MAX_ERRORS) break;
  }

  return errors.length ? { operations: [{ validationAdd: { errors } }] } : { operations: [] };
}
src/run.graphql
# The input query is half the work: a Function only sees what it asks for, and the
# input is capped at 128 kB, so an order pad with 200 lines cannot afford stray fields.
# `casePack` is an alias, so the metafield arrives under a name that reads as code.

query RunInput {
  buyerJourney {
    step
  }
  cart {
    lines {
      quantity
      merchandise {
        __typename
        ... on ProductVariant {
          title
          casePack: metafield(namespace: "b2b", key: "case_pack") {
            value
          }
        }
      }
    }
  }
}

Cuarenta líneas, una petición

El problema

Un bloc de pedido B2B con 40 SKUs y una casilla de cantidad en cada fila. Con una petición por línea el carrito parpadea cuarenta veces y la última respuesta puede llegar antes que la primera. La API de carrito acepta un array, y de paso devuelve el HTML de las secciones que hay que repintar.

La trampa

AbortController: el segundo clic cancela el primero en lugar de competir con él. Y cuando una variante está agotada, el 422 trae una descripción legible; enseñarla sale más barato que atender el correo.

assets/quick-order.js
// B2B order pad. A buyer fills quantities across a table of 40 SKUs and adds the
// lot at once: one request instead of forty, the cart UI re-rendered from that same
// response, and a second click that cancels the first instead of racing it.
//
// <quick-order data-sections="cart-icon-bubble,cart-drawer">
//   <input type="number" data-variant-id="43210" min="0" step="6" value="0">
//   ...
//   <button type="submit">Add to cart</button>
//   <p role="status" aria-live="polite"></p>
// </quick-order>

class QuickOrder extends HTMLElement {
  #request = null;

  connectedCallback() {
    this.button = this.querySelector('[type="submit"]');
    this.status = this.querySelector('[role="status"]');

    // Inside a form, Enter in any quantity field has to work too, and listening to
    // both submit and click would send the batch twice.
    const form = this.querySelector('form');
    if (form) form.addEventListener('submit', this);
    else this.button?.addEventListener('click', this);
  }

  handleEvent(event) {
    event.preventDefault();
    this.add();
  }

  get lines() {
    return [...this.querySelectorAll('input[data-variant-id]')]
      .map((input) => ({ id: Number(input.dataset.variantId), quantity: Number(input.value) }))
      .filter((line) => line.quantity > 0);
  }

  async add() {
    const items = this.lines;
    if (items.length === 0) return;

    this.#request?.abort();
    this.#request = new AbortController();
    this.#busy(true);

    try {
      // Bundled section rendering: the cart markup comes back with the line items,
      // so the drawer and the bubble update without a second round trip.
      const response = await fetch(`${window.Shopify.routes.root}cart/add.js`, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
        body: JSON.stringify({ items, sections: this.dataset.sections }),
        signal: this.#request.signal,
      });
      const body = await response.json();

      // A sold-out line answers 422 with a readable description: show it, don't swallow it.
      if (!response.ok) throw new Error(body.description || body.message);

      for (const [id, html] of Object.entries(body.sections ?? {})) {
        const target = document.getElementById(`shopify-section-${id}`);
        if (target) target.innerHTML = html;
      }

      this.dispatchEvent(new CustomEvent('cart:updated', { bubbles: true, detail: body }));
      this.#say(`${items.length} lines added`);
    } catch (error) {
      if (error.name === 'AbortError') return; // superseded by a newer click
      this.#say(error.message);
    } finally {
      this.#busy(false);
    }
  }

  #busy(state) {
    if (!this.button) return;
    this.button.disabled = state;
    this.button.setAttribute('aria-busy', String(state));
  }

  #say(message) {
    if (this.status) this.status.textContent = message;
  }
}

customElements.define('quick-order', QuickOrder);

Manda la sede, no el cliente

El problema

Un comprador B2B compra para varias sedes de su empresa, y la sede decide el catálogo, la lista de precios y las condiciones de pago. Si el selector vive escondido en la página de cuenta, el carrito se llena con los precios de la sede equivocada.

La trampa

customer.b2b? es falso para quien no ha iniciado sesión, así que no sirve por sí solo para ocultar precios al público. Y al cambiar de sede cambia el catálogo entero: cualquier cosa cacheada por página tiene que variar con ella.

snippets/b2b-location-picker.liquid
{%- comment -%}
  A B2B buyer belongs to a company and often buys for more than one location. The
  catalog, the price list and the payment terms all follow the location, so the
  picker belongs in the header, before the cart fills up with the wrong prices.

  Objects: customer.b2b?, customer.current_company, customer.current_location,
  customer.company_available_locations, location.url_to_set_as_current.
{%- endcomment -%}

{%- if customer.b2b? -%}
  {%- assign locations = customer.company_available_locations -%}

  <div class="b2b-bar">
    <p class="b2b-bar__who">
      {{ customer.current_company.name }} ·
      <strong>{{ customer.current_location.name }}</strong>
    </p>

    {%- if locations.size > 1 -%}
      {%- comment -%} A details element: it works before the JS bundle arrives. {%- endcomment -%}
      <details class="b2b-bar__picker">
        <summary>{{ 'b2b.location.change' | t }}</summary>
        <ul>
          {%- for location in locations -%}
            {%- unless location.current? -%}
              <li>
                <a href="{{ location.url_to_set_as_current }}">
                  {{ location.name }}
                  <span>{{ location.shipping_address.city }}</span>
                </a>
              </li>
            {%- endunless -%}
          {%- endfor -%}
        </ul>
      </details>
    {%- endif -%}
  </div>
{%- endif -%}

Esta web, por dentro

Un portfolio lento sería una contradicción. Estos son los números y lo que hay detrás de ellos.

Rendimiento
96
Accesibilidad
100
Prácticas recomendadas
100
SEO
92
LCP
2 s
CLS
0
TBT
0 ms
Peso
299 KiB
Peticiones
20

Lighthouse 12, móvil emulado con 4G, medido el 21 de octubre de 2026 sobre https://nicolasmateos.com/. Compruébalo tú mismo en PageSpeed Insights ↗

Las decisiones

  • HTML estático. Astro genera las páginas en el build; en el navegador no hay framework, solo el JavaScript propio del menú, el modal de proyectos y el formulario.
  • Las imágenes se generan antes del build con sharp (AVIF y WebP, tres anchos) y se sirven con width y height. Por eso el CLS es 0.
  • Fuentes variables desde el propio dominio, con preload solo de la que pinta el titular.
  • Cloudflare sirve los archivos ya construidos; los que llevan hash en el nombre van con immutable y un año de caché.
  • El Checkout Lab, lo más pesado de la portada, no se descarga hasta que estás a 600 px de él, y hasta que responde se queda inert: ni foco de teclado ni lectura de pantalla sobre un widget que todavía no contesta.

¿Tienes un proyecto Shopify?

Escríbeme