Добавление разных изображений для вариантов продукта

Разные изображения для вариантов в теме Horizon от Shopify (без приложений и без Plus). Бесплатное решение от VecomLab.

Актуально для версии темы Horizon 3.1.0


Если вы хотите избежать настройки кода, попробуйте тему Shrine.

Более 120 встроенных функций, которые помогают улучшить конверсию и увеличить средний чек без необходимости настраивать дополнительные приложения и платить за подписки. Всё настраивается без кода, что экономит время и ресурсы.

Shrine − 15% скидка

Ваш промокод: VECOMLAB

Описание проблемы

По умолчанию в админке Shopify и в настройках темы Horizon каждому варианту можно задать только одно изображение, но не несколько.

Эту проблему платформа частично решает для магазинов на тарифе Plus. Также можно использовать сторонние приложения Shopify, однако это чаще всего невыгодно и неэффективно, так как нередко возникают ошибки, которые приходится исправлять через поддержку месяцами.

Задачу можно решить, используя Metafields and Metaobjects для Variants и редактор кода темы.

Идея

  • У варианта есть метаполе custom.variant_gallery (список файлов).

  • Мы считаем «своими» все картинки, чьё имя файла совпадает со стемом из метаполя или начинается со стема + _ или -.

  • На сервере (Liquid) делаем пред-скрытие лишних картинок для первого кадра.

  • На клиенте (JS) читаем карту «вариант → файлы» и фильтруем при переключении варианта.

  • Если ни у одного варианта метаполя нет — ничего не меняется, всё работает как в теме.

Что нового: зум и quick add

  1. Зум-диалог теперь тоже фильтруется по варианту: и миниатюры, и большие кадры.

  2. Mobile quick add (модалка на странице коллекции): на мобильных в модалке показывается только одно изображение — featured текущего варианта (как в стандартной Horizon), даже если у варианта есть целая серия.

Шаг 1. Метаполе варианта (единожды на весь магазин)

Settings → Metafields and metaobjects → Variants → Add definition

  • Name: Variant Gallery

  • Namespace and key: custom.variant_gallery

  • Type: File → List of files

  • Validations: только Images

  • Options: включить Storefront API access

  • Save

Почему так: это нативный способ связать вариант с файлами в Shopify Files. Ключ совпадает с тем, что использует код.

Шаг 2. Договор по именам файлов

В Files даём серии фото общего варианта общий стем:

ring-gold.jpg
ring-gold_2.jpg
ring-gold-3.jpg

В метаполе варианта прикрепляем один «якорный» файл из серии (например, ring-gold.jpg). Остальные подхватятся по стему автоматически.

Шаг 3. Вставки в _product-media-gallery.liquid

Ниже перечислено ровно то, что мы добавили/заменили, и зачем.

3.1. JSON-карта вариант → файлы (сервер → фронт)

Вставить перед {%- if has_image_drop -%}:

{%- comment -%} Map variant.id -> image basenames from metafield custom.variant_gallery {%- endcomment -%}
{%- liquid
  assign prod = product | default: selected_product
-%}
<script id="variant-galleries-json" type="application/json">
{
{%- for v in prod.variants -%}
  "{{ v.id }}": [
    {%- liquid
      assign mf_raw = v.metafields['custom']['variant_gallery']
      assign files = mf_raw.value | default: mf_raw | default: v.metafields.custom.variant_gallery
    -%}
    {%- if files != blank -%}
      {%- for f in files -%}
        {%- assign base = f | file_url | split:'?' | first | split:'/' | last | downcase -%}
        "{{ base }}"{% unless forloop.last %},{% endunless %}
      {%- endfor -%}
    {%- endif -%}
  ]{% unless forloop.last %},{% endunless %}
{%- endfor -%}
}
</script>

Зачем: фронт получает готовый JSON без парсинга DOM. Сразу приводим URL к базовому имени.

В атрибутах тега, рядом с data-presentation и style, добавить:

data-initial-variant-id="{{ selected_product.selected_or_first_available_variant.id }}"
data-vg-ready="0"
  • data-vg-ready="0" — анти-мигание: пока скрипт не закончит, картинки скрыты (ниже будет CSS).

  • data-initial-variant-idопционально, для отладки/аналитики; сам скрипт берёт id варианта из формы.

Если хотите максимально «чисто» — можно удалить data-initial-variant-id. Функциональность не изменится.

3.3. vg_list для первого рендера

Сразу после {{ block.shopify_attributes }}:

{%- liquid
  assign v = selected_product.selected_or_first_available_variant
  assign mf_raw = v.metafields['custom']['variant_gallery']
  assign vg_list = mf_raw.value | default: mf_raw | default: v.metafields.custom.variant_gallery
-%}

Зачем: знать список файлов текущего варианта на сервере, чтобы пред-скрыть лишние картинки ещё до JS (правильный первый кадр).

Перед вычислением class/style/attributes каждого медиа:

{% assign prehide = false %}
{% if vg_list != blank and media.media_type == 'image' %}
  {% assign media_base = media.preview_image.src | split:'?' | first | split:'/' | last | downcase %}
  {% assign media_stem = media_base | split:'.' | first %}

  {% assign allowed = false %}
  {% for f in vg_list %}
    {% assign f_base = f | file_url | split:'?' | first | split:'/' | last | downcase %}
    {% assign f_stem = f_base | split:'.' | first %}
    {% assign prefix = media_stem | slice: 0, f_stem.size %}
    {% assign next   = media_stem | slice: f_stem.size, 1 %}
    {% if prefix == f_stem %}
      {% if next == '' or next == '_' or next == '-' %}
        {% assign allowed = true %}
        {% break %}
      {% endif %}
    {% endif %}
  {% endfor %}

  {% unless allowed %}
    {% assign prehide = true %}
  {% endunless %}
{% endif %}

И далее добавляем vg-prehide и display:none:

{% capture class %}
  {{ product_media_container_class }} product-media-container--{{ media.media_type }}{% if block.settings.zoom %} product-media-container--zoomable{% endif %}{% if forloop.index0 == lowest_aspect_ratio_index and block.settings.aspect_ratio == 'adapt' %} product-media-container--tallest{% endif %}{% if prehide %} vg-prehide{% endif %}
{% endcapture %}
{% if block_settings.aspect_ratio == 'adapt' %}
  {% capture style %}
    --media-preview-ratio: {{ media.preview_image.aspect_ratio | default: 1.0 }};{% if prehide %} display:none;{% endif %}
  {% endcapture %}
{% else %}
  {% capture style %}{% if prehide %}display:none;{% endif %}{% endcapture %}
{% endif %}

3.5. Единый набор data-атрибутов на слайд

Перед блоком с attributes:

{%- capture common_data_attrs -%}
  data-media-type="{{ media.media_type }}"
  {%- if media.media_type == 'image' -%}
    data-media-url="{{ media.preview_image.src }}"
    {%- if vg_list != blank -%}
      {%- if prehide -%}
        data-vg-prehide="1"
      {%- else -%}
        data-vg-allow="1"
      {%- endif -%}
    {%- else -%}
      data-vg-allow="1"
    {%- endif -%}
  {%- endif -%}
{%- endcapture -%}

И заменяем блок attributes на варианты с подстановкой {{- common_data_attrs -}} (для model / image / «без zoom»).

Было:

{% if block_settings.zoom and media.media_type == 'model' %}
  {%- capture attributes -%}"{% if settings.transition_to_main_product and forloop.first %} data-view-transition-type="product-image-transition"{% endif %}{% endcapture -%}
    {% elsif block_settings.zoom %}
  {%- capture attributes -%}on:click="#zoom-dialog-{{ block.id }}/open/{{ forloop.index0 }}"{% if settings.transition_to_main_product and forloop.first %} data-view-transition-type="product-image-transition"{% endif %}{% endcapture -%}
{% endif %}

Стало:

{%- if block_settings.zoom and media.media_type == 'model' -%}
  {%- capture attributes -%}
    {{- common_data_attrs -}}
    {% if settings.transition_to_main_product and forloop.first %} data-view-transition-type="product-image-transition"{% endif %}
  {%- endcapture -%}
{%- elsif block_settings.zoom -%}
  {%- capture attributes -%}
    {{- common_data_attrs -}}
    on:click="#zoom-dialog-{{ block.id }}/open/{{ forloop.index0 }}"
    {% if settings.transition_to_main_product and forloop.first %} data-view-transition-type="product-image-transition"{% endif %}
  {%- endcapture -%}
{%- else -%}
  {%- capture attributes -%}
    {{- common_data_attrs -}}
    {% if settings.transition_to_main_product and forloop.first %} data-view-transition-type="product-image-transition"{% endif %}
  {%- endcapture -%}
{%- endif -%}

Зачем: фронту нужны тип и URL картинки. data-vg-allow/data-vg-prehide помогают CSS до инициализации JS.

3.6. То же — для Grid

Внутри <ul class="media-gallery__grid"> перед каждым <li> повторяем логику prehide

{% assign prehide = false %}
{% if vg_list != blank and media.media_type == 'image' %}
  {% assign media_base = media.preview_image.src | split:'?' | first | split:'/' | last | downcase %}
  {% assign media_stem = media_base | split:'.' | first %}

  {% assign allowed = false %}
  {% for f in vg_list %}
    {% assign f_base = f | file_url | split:'?' | first | split:'/' | last | downcase %}
    {% assign f_stem = f_base | split:'.' | first %}
    {% assign prefix = media_stem | slice: 0, f_stem.size %}
    {% assign next   = media_stem | slice: f_stem.size, 1 %}
    {% if prefix == f_stem %}
      {% if next == '' or next == '_' or next == '-' %}
        {% assign allowed = true %}
        {% break %}
      {% endif %}
    {% endif %}
  {% endfor %}

  {% unless allowed %}
    {% assign prehide = true %}
  {% endunless %}
{% endif %}

А в самом <li> для Grid заменяем class= "" и style "" на:

class="{{ product_media_container_class }} product-media-container--{{ media.media_type }}{% if prehide %} vg-prehide{% endif %}"
style="{% if block_settings.aspect_ratio == 'adapt' %} --media-preview-ratio: {{ media.preview_image.aspect_ratio | default: 1.0 }};{% endif %}{% if prehide %} display:none;{% endif %}"

И добавляем data-атрибуты:

{% if settings.transition_to_main_product and forloop.first %}
  data-view-transition-type="product-image-transition"
{% endif %}
  data-media-type="{{ media.media_type }}"
  {% if media.media_type == 'image' %}
    data-media-url="{{ media.preview_image.src }}"
    {% if vg_list != blank %}
      {% if prehide %}
        data-vg-prehide="1"
      {% else %}
        data-vg-allow="1"
      {% endif %}
    {% else %}
      data-vg-allow="1"
    {% endif %}
  {% endif %}

3.7. Фильтрация внутри зум-диалога (Liquid)

Миниатюры зума (1)

Внутри блока с миниатюрами (кнопки .dialog-thumbnails-list__thumbnail) добавьте пред-скрытие, как мы делали для слайдов/грида:

{% assign prehide = false %}
{% if vg_list != blank and media.media_type == 'image' %}
  {% assign media_base = media.preview_image.src | split:'?' | first | split:'/' | last | downcase %}
  {% assign media_stem = media_base | split:'.' | first %}

  {% assign allowed = false %}
  {% for f in vg_list %}
    {% assign f_base = f | file_url | split:'?' | first | split:'/' | last | downcase %}
    {% assign f_stem = f_base | split:'.' | first %}
    {% assign prefix = media_stem | slice: 0, f_stem.size %}
    {% assign next   = media_stem | slice: f_stem.size, 1 %}
    {% if prefix == f_stem %}
      {% if next == '' or next == '_' or next == '-' %}
        {% assign allowed = true %}
        {% break %}
      {% endif %}
    {% endif %}
  {% endfor %}

  {% unless allowed %}
    {% assign prehide = true %}
  {% endunless %}
{% endif %}

В <button> замените class="" и style="" :

class="button button-unstyled dialog-thumbnails-list__thumbnail{% if prehide %} vg-prehide{% endif %}"
style="--aspect-ratio: {{ aspect_ratio }}; --gallery-aspect-ratio: {{ aspect_ratio }};{% if prehide %} display:none;{% endif %}"

И добавьте атрибуты:

data-media-type="{{ media.media_type }}"
{% if media.media_type == 'image' %}
  data-media-url="{{ media.preview_image.src }}"
  {% if vg_list != blank %}
    {% if prehide %}
      data-vg-prehide="1"
    {% else %}
      data-vg-allow="1"
    {% endif %}
  {% else %}
    data-vg-allow="1"
  {% endif %}
{% endif %}

Большие кадры в зуме (2)

Для <li> внутри .dialog-zoomed-gallery примените ту же проверку prehide и те же data-атрибуты.

Вставьте проверку перед <li>

{% assign prehide = false %}
{% if vg_list != blank and media.media_type == 'image' %}
  {% assign media_base = media.preview_image.src | split:'?' | first | split:'/' | last | downcase %}
  {% assign media_stem = media_base | split:'.' | first %}

  {% assign allowed = false %}
  {% for f in vg_list %}
    {% assign f_base = f | file_url | split:'?' | first | split:'/' | last | downcase %}
    {% assign f_stem = f_base | split:'.' | first %}
    {% assign prefix = media_stem | slice: 0, f_stem.size %}
    {% assign next   = media_stem | slice: f_stem.size, 1 %}
    {% if prefix == f_stem %}
      {% if next == '' or next == '_' or next == '-' %}
        {% assign allowed = true %}
        {% break %}
      {% endif %}
    {% endif %}
  {% endfor %}

  {% unless allowed %}
    {% assign prehide = true %}
  {% endunless %}
{% endif %}

Замените class="" и style="" в <li>

class="{{ product_media_container_class }} product-media-container--{{ media.media_type }}{% if media.media_type == 'image' %} product-media-container--zoomable{% endif %}{% if prehide %} vg-prehide{% endif %}"
style="{% if block_settings.aspect_ratio == 'adapt' %} --media-preview-ratio: {{ media.preview_image.aspect_ratio | default: 1.0 }};{% endif %}{% if prehide %} display:none;{% endif %}"

И добавьте атрибуты:

data-media-type="{{ media.media_type }}"
{% if media.media_type == 'image' %}
  data-media-url="{{ media.preview_image.src }}"
  {% if vg_list != blank %}
    {% if prehide %}
      data-vg-prehide="1"
    {% else %}
      data-vg-allow="1"
    {% endif %}
  {% else %}
    data-vg-allow="1"
  {% endif %}
{% endif %}

Идея та же: обеспечиваем «чистый» первый кадр в зуме ещё до выполнения JS.

Скрипт:

<script>
(() => {
  // ---------- helpers ----------
  const stripSize = s =>
    (s || '').replace(
      /_(?:\d+x\d+|\d+x|x\d+|pico|icon|thumb|small|compact|medium|large|grande|master)(?=\.)/gi,
      ''
    );
  const canon = (u) => {
    try { const x = new URL(u, location.href); return stripSize((x.pathname.split('/').pop() || '')).toLowerCase(); }
    catch { return stripSize(String(u).split('?')[0].split('/').pop() || '').toLowerCase(); }
  };
  const stem = (s) => canon(s).replace(/\.[a-z0-9]+$/i, '');
  const pickUrl = (el) =>
    el.getAttribute('data-media-url') ||
    el.querySelector('img')?.currentSrc ||
    el.querySelector('img')?.getAttribute('src') || '';

  // ---------- scope ----------
  const THIS = document.currentScript;
  const galleryEl = () =>
    (THIS?.previousElementSibling?.tagName === 'MEDIA-GALLERY'
      ? THIS.previousElementSibling
      : document.querySelector('media-gallery'));

  // NEW: detect we are inside the quick add modal and on a mobile viewport
  const inQuickAdd = () => !!galleryEl()?.closest('#quick-add-modal-content, .quick-add-modal__content');
  const isMobile = () => window.matchMedia('(max-width: 749px)').matches;

  const getVariantId = () =>
    document.querySelector('form[action*="/cart/add"] [name="id"]')?.value ||
    document.querySelector('input[name="id"]')?.value ||
    document.querySelector('select[name="id"]')?.value || null;

  // ---------- read metafield map ----------
  let MAP = {};
  try { const el = document.getElementById('variant-galleries-json'); if (el) MAP = JSON.parse(el.textContent || '{}'); } catch {}
  const HAS_VG = Object.values(MAP).some((a) => Array.isArray(a) && a.length > 0);
  if (!HAS_VG) { galleryEl()?.setAttribute('data-vg-ready', '1'); return; }

  // ---------- accessors ----------
  const zoomRoot   = () => galleryEl()?.parentElement?.querySelector('zoom-dialog') || null;
  const slidesMain = () => Array.from(galleryEl()?.querySelectorAll(':scope > *:not(zoom-dialog) [data-media-type="image"]') || []);
  const slidesAll  = () => Array.from(galleryEl()?.querySelectorAll(':scope > *:not(zoom-dialog) [data-media-type]') || []);
  const dotsAll    = () => Array.from(galleryEl()?.querySelectorAll('slideshow-controls [ref="dots[]"]') || []);
  const slidesZoom = () => Array.from(zoomRoot()?.querySelectorAll('.dialog-zoomed-gallery [data-media-type="image"]') || []);
  const thumbsZoom = () => Array.from(zoomRoot()?.querySelectorAll('.dialog-thumbnails-list__thumbnail[data-media-type="image"]') || []);
  const allowStemsFor = (vid) => (MAP?.[String(vid)] || []).map(stem);

  // ---------- filtering ----------
  const applyList = (els, allow) => {
    const useFilter = allow.length > 0;
    let shown = 0;
    const limitToOne = inQuickAdd() && isMobile(); // In quick add (mobile), keep only the first image

    els.forEach((el) => {
      const base = stem(pickUrl(el));
      let show = !useFilter || (base && allow.some(a => base === a || base.startsWith(a + '_') || base.startsWith(a + '-')));
      if (show && limitToOne) { shown += 1; if (shown > 1) show = false; }
      el.toggleAttribute('data-vg-hidden', !show);
      el.style.display = show ? '' : 'none';
    });
  };

  const applyDots = (allow) => {
    // In quick add on mobile, hide dots entirely
    if (inQuickAdd() && isMobile()) {
      galleryEl()?.querySelectorAll('slideshow-controls').forEach(sc => sc.style.display = 'none');
      return;
    }
    const useFilter = allow.length > 0;
    const slides = slidesAll();
    const dots = dotsAll();
    if (!slides.length || !dots.length) return;

    dots.forEach((dotBtn, i) => {
      const slide = slides[i];
      if (!slide) return;
      const type = slide.getAttribute('data-media-type');
      let show = true;
      if (type === 'image') {
        const base = stem(pickUrl(slide));
        show = !useFilter || (base && allow.some(a => base === a || base.startsWith(a + '_') || base.startsWith(a + '-')));
      }
      dotBtn.toggleAttribute('data-vg-hidden', !show);
      (dotBtn.closest('li') || dotBtn).style.display = show ? '' : 'none';
    });

    const dotsNow = dotsAll();
    const current = dotsNow.find(d => d.getAttribute('aria-selected') === 'true');
    if (current && current.hasAttribute('data-vg-hidden')) {
      const firstVisible = dotsNow.find(d => !d.hasAttribute('data-vg-hidden'));
      if (firstVisible) firstVisible.click();
    }
  };

  const apply = (variantId) => {
    const allow = allowStemsFor(variantId);
    applyList(slidesMain(), allow);
    applyList(slidesZoom(), allow);
    applyList(thumbsZoom(), allow);
    applyDots(allow);
    galleryEl()?.setAttribute('data-vg-ready', '1');
    galleryEl()?.dispatchEvent(new CustomEvent('variantMediaFiltered', { bubbles: true, detail: { variantId } }));
  };

  // ---------- bindings ----------
  const onVariantChange = () => apply(String(getVariantId() || ''));
  ['change','input'].forEach(t => document.addEventListener(t, (e) => { if (e.target?.name === 'id') onVariantChange(); }, { capture:true, passive:true }));
  ['selectVariant','variant:change','variant:changed'].forEach(evt => document.addEventListener(evt, (ev) => { if (ev?.detail?.variant?.id) onVariantChange(); }, { passive:true }));
  new MutationObserver(() => onVariantChange()).observe(document.body, { childList:true, subtree:true });

  // ---------- init ----------
  const init = () => { galleryEl()?.setAttribute('data-vg-ready', '0'); onVariantChange(); };
  if (document.readyState !== 'loading') init(); else document.addEventListener('DOMContentLoaded', init, { once:true });
})();
</script>
  • читает #variant-galleries-json;

  • если у товара нет ни одного варианта с метаполем → ставит data-vg-ready="1" и выходит (ничего не меняем);

  • иначе фильтрует image-слайды по текущему варианту; при смене варианта — повторяет;

  • в конце ставит data-vg-ready="1" (анти-мигание выключается).

CSS:

<style>
  media-gallery [data-media-type="image"] * { transition:none!important; animation:none!important; }
  media-gallery:not([data-vg-ready="1"]) [data-media-type="image"] { display:none!important; }
  media-gallery:not([data-vg-ready="1"]) [data-media-type="image"][data-vg-allow="1"] { display:flex!important; }
  [data-vg-hidden], [data-vg-prehide] { display:none!important; }
  media-gallery:not([data-vg-ready="1"]) slideshow-controls .slideshow-controls__dots { display: none !important; }
</style>

<style>
  /* QUICK ADD (mobile): keep only the first allowed image before JS initialization */
  @media (max-width: 749px) {
    #quick-add-modal-content media-gallery:not([data-vg-ready="1"]) [data-media-type="image"][data-vg-allow="1"] ~ [data-media-type="image"][data-vg-allow="1"],
    .quick-add-modal__content  media-gallery:not([data-vg-ready="1"]) [data-media-type="image"][data-vg-allow="1"] ~ [data-media-type="image"][data-vg-allow="1"] {
      display: none !important;
    }
    /* also hide navigation controls in the modal */
    #quick-add-modal-content slideshow-controls,
    .quick-add-modal__content  slideshow-controls {
      display: none !important;
    }
  }
</style>

Зачем: полностью убирает «дрожание/моргание» и промежуточные состояние до готовности. Мы не трогаем миниатюры/стрелки — в «чистом» варианте данной версии скрипта их и не скрываем.

Почему это нужно:

  • пока стоит data-vg-ready="0", модалка не «мигает» набором картинок;

  • JS дополнительно гарантирует, что даже после переключений варианта в модалке будет ровно одно изображение.

Что сознательно не трогаем (остается «как в теме»)

Сделаем это в следующих версиях скрипта:

  • Счётчик / стрелки / миниатюры карусели — не настраиваем и не прячем отдельными правилами.

Небольшой аудит «лишнего»

  • data-initial-variant-idопция, можно удалить (скрипт не сломает).

  • MutationObserver сейчас повешен на document.body для «неубиваемости» при динамических перерисовках. Можно ужать до наблюдения только за <media-gallery>, но текущий вариант надёжнее ко всем темам/скриптам.

  • Дублирование логики «пред-скрыт на сервере» + «фильтр на клиенте» — осознанно: даёт идеальный первый кадр (без мусора) и корректную работу при дальнейших переключениях. Это не лишнее, а анти-FOUC-слой.

Итог

Если у варианта заполнено метаполе Variant Gallery, на странице показываются только его фотографии (по имени файла). Если метаполя нет — всё работает как раньше. Первый кадр чистый без миганий (пред-скрытие на сервере), при переключении варианта галерея обновляется скриптом. Никаких приложений, конфликтов и замедлений.

Настройка темы

Режимы Product media (Grid + Hint) и Grid + Dots поддерживаются. Поведение «одно изображение в Mobile quick add» включается только внутри модалки на экранах ≤ 749 px; на странице товара и на десктопе остаётся полноценная галерея по варианту.

Вы можете задать вопросы в телеграм-чат.

FAQ

Что мне вообще нужно настроить в админке?

Только одно: создать метаполе варианта custom.variant_gallery типа File → List of files (только Images) и включить Storefront API access. Всё остальное — вставки в один шаблон _product-media-gallery.liquid.

Сколько файлов прикреплять к метаполю варианта?

Достаточно одного «якорного» файла из серии. Остальные подхватятся автоматически по имени (по «стему»), если названы stem.jpg, stem_2.jpg, stem-3.jpg и т. п.

Как именно должен называться файл?

У серии фото одной модификации должен быть общий стем (база имени файла): ring-gold.jpg, ring-gold_2.jpg, ring-gold-3.jpg. Совпадение идёт по точному стему или стему с суффиксом _ / -. Регистр и размеры Shopify (_800x) не влияют — скрипт их «чистит».

А если у варианта метаполе пустое?

Тогда для него показываются все изображения товара (как в исходной теме). Логика включается только если для варианта нашлось хотя бы одно прикреплённое изображение.

Что будет, если ни у одного варианта метаполя нет?

Скрипт это видит, ничего не делает и сразу ставит data-vg-ready="1". Галерея работает ровно как в теме, без дополнительных действий и влияния на производительность.

Будут ли «мигания» картинок при загрузке или при переключении варианта?

Нет. Мы использовали двойную защиту:

  • на стороне Liquid — «пред-скрытие» лишних изображений для первого кадра;

  • на стороне JS — фильтрация при переключениях + флаг data-vg-ready вкупе с мини-CSS, чтобы не было промежуточных состояний на экране.

В зум-диалоге сейчас показываются все фото. Так задумано?

Нет, теперь зум тоже фильтруется. И миниатюры, и большие кадры в диалоге показывают только изображения текущего варианта (по тому же правилу стемов). Первый кадр чистый за счёт пред-скрытия в Liquid; дальше — за счёт скрипта.

Счётчик, стрелки, миниатюры карусели — почему вы их не трогали?

Чтобы решение было максимально совместимым и минимально «инвазивным». Мы не вмешивались в навигационные элементы, кроме Dots, — их можно будет настроить отдельно, когда появится такая задача.

В каких случаях скрипт может «не видеть» id варианта?

Если тема/приложение нестандартно рендерит форму варианта. Скрипт ищет name="id" в форме add to cart. Если поле переименовано или его нет на странице, обновите селектор в функции getVariantId() или бросайте кастомное событие selectVariant/variant:change с detail.variant.id.

У меня «быстрое просмотр»/модальное окно товара — решение сработает?

Как правило — да, т.к. скрипт наблюдает MutationObserver за document.body и повторно применяет фильтр. Если модалка подгружает контент динамически, убедитесь, что блок JSON #variant-galleries-json попадает внутрь её DOM.

Что насчёт производительности?
  • Блок JSON строится один раз на сервере;

  • JS обходит только видимые элементы галереи и работает по простым строковым проверкам;

  • Если метаполей нет — скрипт фактически «выключается».

  • Оверхеда для страниц без метаполей нет.

SEO/Alt-тексты/Structured data не пострадают?

Мы не удаляем элементы, только добавляем display:none в стартовое состояние и переключаем видимость. Alt-тексты и schema.org, которые рендерит тема, остаются. Набор изображений в DOM остаётся тем же, просто часть скрыта, пока вариант иной.

Нужно ли менять порядок медиа в самом товаре?

Не обязательно. Но для UX лучше, чтобы «якорное» фото каждого варианта (тот самый stem) находилось рядом с его серией — так пред-скрытие и первый кадр будут визуально логичнее.

Что если разные варианты случайно получили одинаковый стем?

Тогда обе серии будут считаться «подходящими» для обоих вариантов. Держите нейминг строгим (разные стемы на разные варианты), чтобы не было пересечений.

Можно ли использовать пробелы/Юникод в именах файлов?

Можно, но лучше избегать. Shopify даёт URL, а скрипт работает по базовому имени из URL. Рекомендация: латиница, дефисы или подчёркивания.

А видео и 3D-модели?

Фильтр применяется только к media_type="image". Видео/модели остаются как в теме. Если хотите, чтобы и они «прикреплялись» к вариантам — можно расширить сопоставление по data-media-type и базовым именам постеров/URL.

Что будет, если я позже переименую файл в Files?

Сопоставление строится по имени файла. Если переименуете «якорь» или серию, обновите либо имя «якоря» в метаполе, либо верните прежнее имя; иначе серию может «оторвать» от варианта.

Как откатиться назад?

Удалите добавленный блок JSON, серверные вставки vg_list/prehide, единый блок common_data_attrs, скрипт и мини-CSS. После этого тема вернётся к исходному поведению.

Это работает на всех языках/локалях?

Да. Логика не завязана на тексты; важны только атрибуты и имена файлов.

Может ли это конфликтовать с приложениями-галереями?

Если приложение полностью заменяет разметку галереи, оно, вероятно, игнорирует наши data-атрибуты. Тогда или отключайте приложение на страницах с этой логикой, или переносите фильтрацию в шаблоны приложения.

Безопасно ли обновлять тему?

Майорные апдейты темы могут перезаписать _product-media-gallery.liquid. Держите git/Theme Download и diff: если Shopify поменяет структуру этого шаблона, перенесите наши вставки в новую версию файла.

Можно ли вместо «якорного» файла прикреплять все файлы серии?

Можно, но не нужно. Логика специально построена на стемах, чтобы не «тыкать» каждый файл руками.

Можно ли убрать атрибут data-initial-variant-id?

Да. Он опционален и никак не влияет на работу фильтра.

Что делать, если на странице несколько товаров (панель «рекомендованные», кастомные секции и т. п.)?

В текущем виде скрипт ищет ближайший <media-gallery> рядом с собой. Размещайте его внутри нужного блока товара. Если у вас несколько галерей на одной странице, добавьте уникальные контейнеры/инициализации, чтобы скрипт для каждого товара стоял сразу после его <media-gallery>.

В модалке quick add почему одно фото?

Так задумано: на мобильных в быстром добавлении важно не перегружать экран — показываем featured текущего варианта. На PDP остаётся вся серия.

Last updated

Was this helpful?