↑↓ mover↵ abrir⌘K abrir/cerrar

    templates/page.lab.json

    Lab.

    Los componentes que deciden el ticket promedio y la conversión, reconstruidos en vivo. Cada uno corre las mismas reglas que el código de al lado, y cada uno es algo que he entregado de alguna forma para una tienda real.

    Lo que resuelve cada componente de aquí

    • AccesibleTeclado primero, controles de formulario reales y regiones vivas en cada cambio de precio
    • Del equipoNiveles, metas y textos viven en bloques, settings o metafields, no en el código
    • Funciona sin JSFormularios normales a /cart/add; JavaScript solo lo hace más fluido
    • Listo para MarketsEl dinero pasa por filtros de Liquid y tasas de cambio, nunca por texto
    • Sin saltosEl servidor renderiza el mismo HTML que el script actualiza

    01 · sections/bundle-progress.liquid

    Barra de bundle por niveles

    Entregada en Winona: 2 productos dan 10% de descuento, 3 dan 15% y 4 dan 20%. La barra está encima del header en todas las páginas, así la oferta se ve antes de abrir el carrito.

    • Siempre muestra la siguiente recompensa, no solo la actual
    • Los niveles viven en bloques de la sección, editables sin deploy
    • El mismo HTML al cargar y después de cada cambio del carrito

    Presiona + y − para cambiar el carrito

    Arma tu bundle y ahorra

    1 producto10% dto.2 productos15% dto.3 productos20% dto.4 productos

    Agrega 1 producto más para desbloquear 10% de descuento

    HA + Peptide Repair Serumcon ceramidas + resveratrol · $401 producto$40.00

    Datos de demo, no la tienda real

    {%- liquid
      assign count = cart.item_count
      assign discount = 0
      assign next_tier = nil
    
      for block in section.blocks
        if count >= block.settings.min_items
          assign discount = block.settings.percent_off
        elsif next_tier == nil
          assign next_tier = block
        endif
      endfor
    
      assign fill = count | minus: 1 | at_least: 0 | times: 100 | divided_by: 3
    -%}
    
    <bundle-progress
      data-section-id="{{ section.id }}"
      data-discount="{{ discount }}"
      style="--fill: {{ fill | at_most: 100 }}%"
    >
      {%- if next_tier -%}
        {%- assign left = next_tier.settings.min_items | minus: count -%}
        {{ 'bundle.unlock' | t: count: left, percent: next_tier.settings.percent_off }}
      {%- else -%}
        {{ 'bundle.best_price' | t }}
      {%- endif -%}
    </bundle-progress>

    02 · sections/cart-drawer.liquid

    Carrito lateral con metas

    Dos recompensas en una barra y un upsell que sugiere el producto más barato que de verdad alcanza la siguiente.

    • El upsell se elige por lo que falta, nunca un producto de $40 para cerrar $3
    • Una petición por cambio: el HTML del carrito vuelve en la respuesta
    • Los umbrales se convierten por mercado, así lo que falta es correcto en cada moneda

    Agrega la sugerencia, cambia cantidades o reinicia

    Tu carrito1 producto

    Te faltan $12.00 para envío gratis

    Envío gratis$50Regalo gratis$90
    • Sistema para piel acneicaRutina de 3 pasos · $381$38.00
    Limpiador ártico desbloquea envío gratis
    Subtotal$38.00

    Datos de demo, no la tienda real

    {%- comment -%}
      Goals are blocks the merchant orders and edits in the theme editor. Amounts are in
      the store currency; on Markets stores the drawer converts them with
      Shopify.currency.rate before comparing (see cart-drawer.js).
    {%- endcomment -%}
    {%- liquid
      assign goals = section.blocks | where: 'type', 'goal'
      assign last = goals | last
      assign max = last.settings.amount | times: 100
      assign fill = cart.total_price | times: 100.0 | divided_by: max | at_most: 100
    -%}
    
    <cart-goals style="--fill: {{ fill | round: 1 }}%">
      <p class="goal__msg" aria-live="polite">
        {%- for goal in goals -%}
          {%- assign at = goal.settings.amount | times: 100 -%}
          {%- if cart.total_price < at -%}
            {%- assign gap = at | minus: cart.total_price | money -%}
            {{- 'cart.goal_gap' | t: gap: gap, reward: goal.settings.label -}}
            {%- break -%}
          {%- endif -%}
          {%- if forloop.last -%}{{ 'cart.goals_done' | t }}{%- endif -%}
        {%- endfor -%}
      </p>
    
      <div class="goal__track" role="presentation">
        <div class="goal__fill"></div>
        {%- for goal in goals -%}
          {%- assign at = goal.settings.amount | times: 100 -%}
          <span
            class="goal__tick{% if cart.total_price >= at %} is-hit{% endif %}"
            data-amount="{{ at }}"
            style="--p: {{ at | times: 100.0 | divided_by: max }}%"
            {{ goal.shopify_attributes }}
          >
            {{- goal.settings.label -}}
          </span>
        {%- endfor -%}
      </div>
    </cart-goals>

    03 · snippets/selling-plan-picker.liquid

    Selector de Suscríbete y ahorra

    La suscripción primero, con el precio en lo que la gente compara: por porción y por entrega. Debajo del botón está exactamente lo que el formulario envía al carrito.

    • Cada precio sale de los selling plans de la variante
    • Compra única quita el campo, así nunca se envía un plan vacío
    • Funciona con Recharge, Skio o suscripciones nativas

    Cambia entre suscripción y compra única, y luego la frecuencia

    Burn2OShot líquido quemagrasa · 30 porciones

    POST /cart/add

    Datos de demo, no la tienda real

    {%- liquid
      assign variant = product.selected_or_first_available_variant
      assign group = product.selling_plan_groups | first
      assign first_plan = variant.selling_plan_allocations | first
    -%}
    
    <selling-plan-picker data-variant-id="{{ variant.id }}">
      {%- unless product.requires_selling_plan -%}
        <label class="plan__opt">
          <input type="radio" name="purchase" value="once">
          {{ 'products.one_time' | t }} <strong>{{ variant.price | money }}</strong>
        </label>
      {%- endunless -%}
    
      {%- if first_plan -%}
        {%- assign adj = first_plan.selling_plan.price_adjustments | first -%}
        <label class="plan__opt">
          <input type="radio" name="purchase" value="sub" checked>
          {%- if adj.value_type == 'percentage' -%}
            {{ 'products.subscribe_save' | t: percent: adj.value }}
          {%- endif -%}
          <strong>{{ first_plan.price | money }}</strong>
          <s>{{ first_plan.compare_at_price | money }}</s>
        </label>
    
        <fieldset class="plan__freq">
          {%- for plan in group.selling_plans -%}
            <label>
              <input type="radio" name="selling_plan" value="{{ plan.id }}"
                {% if plan.id == first_plan.selling_plan.id %}checked{% endif %}>
              {{ plan.name }}
            </label>
          {%- endfor -%}
        </fieldset>
      {%- endif -%}
    
      {%- comment -%} Price per serving from a product metafield {%- endcomment -%}
      {%- assign servings = product.metafields.custom.servings.value | default: 1 -%}
      <p>{{ first_plan.price | divided_by: servings | money }} / {{ 'products.serving' | t }}</p>
    </selling-plan-picker>

    04 · extensions/qty-breaks · Shopify Function

    Descuentos por cantidad con una Function

    La PDP muestra los niveles y una Discount Function los aplica en el carrito y el checkout, así el precio no se aleja de lo que prometió la página.

    • Corre en los servidores de Shopify: sin script tags ni draft orders
    • Los niveles son un metafield JSON del descuento, editable en el admin
    • Solo aplican productos con tag, y hay pruebas para cada nivel

    Elige un paquete y mira cómo cambia la salida de la Function

    Smart Amino BlendProteína inteligente · 30 porciones

    cart_lines_discounts_generate_run → output

    Datos de demo, no la tienda real

    // @ts-check
    import { DiscountClass, ProductDiscountSelectionStrategy } from '../generated/api';
    
    /**
     * Quantity breaks. Tiers live in a JSON metafield on the discount, so the team
     * changes them in the admin without a deploy: { "tiers": [{ "min": 2, "percent": 10 }] }
     *
     * @param {import('../generated/api').CartInput} input
     * @returns {import('../generated/api').CartLinesDiscountsGenerateRunResult}
     */
    export function cartLinesDiscountsGenerateRun(input) {
      if (!input.discount.discountClasses.includes(DiscountClass.Product)) {
        return { operations: [] };
      }
    
      /** @type {{ tiers: Array<{ min: number, percent: number }> }} */
      const config = input.discount.metafield?.jsonValue ?? { tiers: [] };
      const tiers = [...config.tiers].sort((a, b) => b.min - a.min);
    
      const candidates = input.cart.lines.flatMap((line) => {
        const eligible = line.merchandise.__typename === 'ProductVariant' && line.merchandise.product.breaks;
        const tier = eligible && tiers.find((t) => line.quantity >= t.min);
        if (!tier) return [];
        return [{
          message: `${tier.percent}% off ${tier.min}+`,
          targets: [{ cartLine: { id: line.id } }],
          value: { percentage: { value: tier.percent } },
        }];
      });
    
      if (!candidates.length) return { operations: [] };
      return {
        operations: [
          { productDiscountsAdd: { candidates, selectionStrategy: ProductDiscountSelectionStrategy.All } },
        ],
      };
    }

    05 · snippets/sticky-atc.liquid

    Botón de compra fijo

    El botón de compra acompaña al cliente cuando el real sale de la pantalla, y se quita cuando vuelven ese botón o el footer. Envía el mismo formulario de producto, así nada se desfasa.

    • Un solo formulario: la barra lo apunta con form="…", sin inputs duplicados
    • Aparece solo después de pasar el botón y se oculta en el footer
    • Variante, precio y stock se sincronizan en cada cambio

    Desplázate por la página de producto y presiona Agregar al carrito en la barra

    Mezcla Balance Diario

    60 cápsulas · 30 días

    $49.00

    1
    Mezcla Balance Diario1 frasco · $49.00

    Botón sin pasar · barra oculta

    POST /cart/add Presiona cualquiera de los dos botones

    Datos de demo, no la tienda real

    {%- doc -%}
      Sticky Add to Cart bar for the PDP. Render it once, right after the product form.
    
      @param {product} product - The product on the page
      @param {string} form_id - The id of the product form the bar submits
    
      @example
      {% render 'sticky-atc', product: product, form_id: product_form_id %}
    {%- enddoc -%}
    
    {%- comment -%}
      The button sits outside the product form and points at it with form="…".
      The browser submits that one form: same variant id, quantity, selling plan and
      line item properties as the main button, with no copied hidden inputs to keep
      in sync. Starts inert, so without JavaScript there is no bar and nothing breaks.
    {%- endcomment -%}
    {%- liquid
      assign variant = product.selected_or_first_available_variant
      assign image = variant.featured_image | default: product.featured_image
    -%}
    
    <sticky-atc
      data-form-id="{{ form_id }}"
      data-add-label="{{ 'products.product.add_to_cart' | t }}"
      data-sold-out-label="{{ 'products.product.sold_out' | t }}"
      inert
    >
      <div class="sticky-atc" role="region" aria-label="{{ 'products.sticky_atc.label' | t }}">
        {%- if image -%}
          {{ image | image_url: width: 96, height: 96, crop: 'center' | image_tag: class: 'sticky-atc__img', loading: 'lazy', alt: '' }}
        {%- endif -%}
        <div class="sticky-atc__info">
          <p class="sticky-atc__title">{{ product.title }}</p>
          <p class="sticky-atc__meta">
            {%- unless product.has_only_default_variant -%}
              <span data-variant>{{ variant.title }}</span> ·
            {% endunless -%}
            <span data-price>{{ variant.price | money }}</span>
          </p>
        </div>
        <button
          type="submit"
          form="{{ form_id }}"
          name="add"
          class="button sticky-atc__button"
          {% unless variant.available %}
            disabled
          {% endunless %}
        >
          {%- if variant.available -%}
            {{ 'products.product.add_to_cart' | t }}
          {%- else -%}
            {{ 'products.product.sold_out' | t }}
          {%- endif -%}
        </button>
      </div>
    </sticky-atc>
    
    {% stylesheet %}
      sticky-atc { position: fixed; inset: auto 0 0; z-index: 20; }
      sticky-atc[inert] .sticky-atc { transform: translateY(110%); }
      .sticky-atc {
        display: grid; grid-template-columns: auto minmax(0, 1fr) auto; gap: 12px; align-items: center;
        padding: 10px 16px calc(10px + env(safe-area-inset-bottom));
        background: var(--color-background); box-shadow: 0 -8px 24px rgb(0 0 0 / 0.08);
      }
      .sticky-atc__title { margin: 0; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
      .sticky-atc__meta { margin: 0; }
    {% endstylesheet %}

    07 · extensions/bundle-expand · Shopify Function

    Bundles separados con un Cart Transform

    El cliente compra un kit a un solo precio. Una Cart Transform Function lo separa en los productos reales para el checkout, con el precio repartido entre ellos, así el inventario, el pedido y el almacén ven SKUs reales.

    • Los precios de los productos siempre suman el precio del bundle, al centavo
    • Los productos del bundle viven en un metafield del producto, editable sin deploy
    • Usa el costo de la línea en la moneda del cliente, así Markets no necesita conversión

    Activa el interruptor y cambia la cantidad

    Tu carrito
    Kit calmante de inicioBundle · 4 productos1$64.00$82.96
    Subtotal$64.00

    Una línea: lo que ven el tema y el carrito

    run.graphql → input

    Datos de demo, no la tienda real

    // @ts-check
    
    /**
     * Expands a bundle line into its components, so checkout, the order, inventory and
     * the warehouse see real SKUs while the shopper still pays the bundle price.
     * The components live in a product metafield the app writes when the merchant
     * builds the bundle:
     *   [{ "variantId": "gid://shopify/ProductVariant/7001", "quantity": 1, "listPrice": "39.99" }]
     *
     * @typedef {import("../generated/api").RunInput} RunInput
     * @typedef {import("../generated/api").CartTransformRunResult} CartTransformRunResult
     * @typedef {{ variantId: string, quantity: number, listPrice: string }} Component
     */
    
    /** @type {CartTransformRunResult} */
    const NO_CHANGES = { operations: [] };
    
    /**
     * @param {RunInput} input
     * @returns {CartTransformRunResult}
     */
    export function cartTransformRun(input) {
      const operations = input.cart.lines.flatMap((line) => {
        if (line.merchandise.__typename !== 'ProductVariant') return [];
        /** @type {Component[] | undefined} */
        const components = line.merchandise.product.components?.jsonValue;
        if (!components?.length) return [];
    
        const prices = allocate(line.cost.amountPerQuantity.amount, components);
        return [{
          lineExpand: {
            cartLineId: line.id,
            title: line.merchandise.product.title,
            expandedCartItems: components.map((c, i) => ({
              merchandiseId: c.variantId,
              quantity: c.quantity, // per bundle: Shopify multiplies it by the line quantity
              price: { adjustment: { fixedPricePerUnit: { amount: prices[i] } } },
            })),
          },
        }];
      });
    
      return operations.length ? { operations } : NO_CHANGES;
    }
    
    /**
     * Splits one bundle's price across its components by their list prices, in cents,
     * so the parts add up to exactly what the shopper saw. The line cost is already in
     * the buyer's currency and the split is a ratio, so Markets needs no rate here.
     *
     * @param {string} bundlePrice
     * @param {Component[]} components
     * @returns {string[]} unit price per component
     */
    export function allocate(bundlePrice, components) {
      const total = Math.round(Number(bundlePrice) * 100);
      const weight = components.map((c) => Number(c.listPrice) * c.quantity);
      const sum = weight.reduce((a, b) => a + b, 0) || 1;
      const units = components.map((c, i) => Math.floor((total * weight[i]) / sum / c.quantity));
      // Leftover cents go to a component sold as one unit, where they land exactly
      const spent = units.reduce((s, u, i) => s + u * components[i].quantity, 0);
      const single = components.findIndex((c) => c.quantity === 1);
      if (single > -1) units[single] += total - spent;
      return units.map((cents) => (cents / 100).toFixed(2));
    }

    snippets/workbench.liquid

    En el taller.

    Lo siguiente en el Lab. Mismas reglas: demo en vivo, el código y la razón por la que se gana su lugar en la página.

    1. 08Extensión de checkout: un upsell de un clic debajo del resumen del pedido
    2. 09Quiz de producto que guarda el resultado en un metafield del cliente
    3. 10Selector de variantes con combined listings y swatches desde metaobjetos

    sections/contact.liquid

    Construyamosel siguiente.

    ¿Buscas a alguien senior en Shopify, necesitas rehacer una tienda, construir una app o automatizar un proceso? Escríbeme.