Navigating Shopify's RTL Challenges: Fixing Arabic Variant Swatch Mapping & API Injections

Hey everyone! I recently saw a fascinating and frankly, pretty tricky, issue pop up in the community that I wanted to chat about. It came from a store owner, dorame, who was wrestling with a classic Shopify challenge: getting Right-to-Left (RTL) Arabic localization to play nicely with custom variant swatches and a complex Storefront API setup. If you’ve ever ventured into multi-language stores, especially with custom code, you know this isn’t always a walk in the park. But when you throw in dynamic variant loading for products with over 250 variants, things get even more interesting!

Understanding the Core Problem: When Swatches Go Silent in Arabic

dorame’s situation is a perfect example of how localization can introduce subtle but significant bugs. The problem description was crystal clear: everything works perfectly in English (LTR), but as soon as the store switches to Arabic (RTL), the variant swatches break. We’re talking about swatches disappearing, variant mapping getting confused, and selected options failing to resolve to the correct product variant. It’s a frustrating scenario because the core functionality is there, just not for a specific language.

The setup itself is robust: a custom storefront leveraging the Shopify Storefront API. Because Shopify’s default product payload limits variants, dorame’s store intelligently injects any variants beyond the initial 250 using the API. This is a smart approach for managing large catalogs, but it adds another layer of complexity when localization enters the picture.

Here’s the code snippet from dorame’s product-variant-picker.liquid that handles the API fetching and variant injection:

window.ShopifyProduct = {
    variants: {{ product.variants | json }}
  };

  (function () {
    var DECLARED_COUNT = {{ product.variants_count }};
    var STOREFR;
    var PRODUCT_GID = 'gid://shopify/Product/{{ product.id }}';
    var SHOP_DOMAIN = '{{ shop.permanent_domain }}';

    if (DECLARED_COUNT <= 250) return;

    var PAGE_SIZE = 250;
    var allVariants = window.ShopifyProduct.variants.slice();
    var existingIds = new Set(allVariants.map(function(v) { return v.id; }));

    function buildQuery(cursor) {
      var afterClause = cursor ? ', after: "' + cursor + '"' : '';
      return JSON.stringify({
        query: 'query { product(id: "' + PRODUCT_GID + '") { variants(first: ' + PAGE_SIZE + afterClause + ') { pageInfo { hasNextPage endCursor } edges { node { id title sku availableForSale quantityAvailable selectedOptions { name value } price { amount } image { url transformedSrc(maxWidth: 400, maxHeight: 400, crop: CENTER) } } } } } }'
      });
    }

    function mapVariant(node) {
      var opts = node.selectedOptions.map(function(o) { return o.value; });
      return {
        id:                 parseInt(node.id.replace('gid://shopify/ProductVariant/', '')),
        title:              node.title,
        sku:                node.sku,
        available:          node.availableForSale,
        options:            opts,
        option1:            opts[0] || null,
        option2:            opts[1] || null,
        option3:            opts[2] || null,
        price:              Math.round(parseFloat(node.price.amount) * 100),
        inventory_quantity: node.quantityAvailable,
        featured_image:     node.image ? { src: node.image.transformedSrc || node.image.url } : null
      };
    }

    function mergeBatch(edges) {
      edges.forEach(function(e) {
        var v = mapVariant(e.node);
        if (!existingIds.has(v.id)) {
          allVariants.push(v);
          existingIds.add(v.id);
        }
      });
      window.ShopifyProduct.variants = allVariants;
    }

    window.ShopifyProduct._loading = true;

    function fetchChain(cursor) {
      fetch('https://' + SHOP_DOMAIN + '/api/2024-01/graphql.json', {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
          'X-Shopify-Storefront-Access-Token': STOREFRONT_TOKEN
        },
        body: buildQuery(cursor)
      })
      .then(function(r) { return r.json(); })
      .then(function(data) {
        var c

        mergeBatch(conn.edges);

        // ✅ Fire partial event after every page so variant-selects can
        // progressively inject swatches without waiting for all pages
        document.dispatchEvent(new CustomEvent('shopify:variants:partial', {
          detail: { loaded: allVariants.length }
        }));
        console.log('[Variants] Progressive load: ' + allVariants.length);

        if (conn.pageInfo.hasNextPage) {
          // ✅ Immediately chain next page — zero idle time between pages
          fetchChain(conn.pageInfo.endCursor);
        } else {
          finalize();
        }
      })
      .catch(function(e) {
        console.warn('[Variants] Fetch failed, using what we have.', e);
        finalize();
      });
    }

    function finalize() {
      window.ShopifyProduct.variants = allVariants;
      window.ShopifyProduct._loading = false;

      injectMissingColorSwatches();

      document.dispatchEvent(new CustomEvent('shopify:variants:loaded', {
        detail: { total: allVariants.length }
      }));
      console.log('[Variants] Total loaded: ' + allVariants.length);
    }

    // Start the chain from the first page
    fetchChain(null);

    function injectMissingColorSwatches(skipFilter) {
      var variants = window.ShopifyProduct.variants;

      var productOpti product.options | json }};

function getOptionIndex(optionNames, possibleNames) {
  for (var i = 0; i < optionNames.length; i++) {
    var normalized = optionNames[i].toLowerCase();

    for (var j = 0; j < possibleNames.length; j++) {
      if (normalized === possibleNames[j].toLowerCase()) {
        return i;
      }
    }
  }
  return -1;
}

      console.log('[Inject] Total variants:', variants.length);

      var combosByGender = {};
      variants.forEach(function(v) {
        var g = v.option1;
        var c = v.option2;
        if (!g || !c) return;
        if (!combosByGender[g]) combosByGender[g] = new Set();
        combosByGender[g].add(c);
      });

      var allColors = new Set();
      Object.values(combosByGender).forEach(function(colorSet) {
        colorSet.forEach(function(c) { allColors.add(c); });
      });

      var variantSelects = document.querySelector('variant-selects');
      var colorFieldset = (variantSelects || document).querySelector('fieldset[data-name="Color"], fieldset[data-name="لون"]');
      if (!colorFieldset) {
        console.warn('[Inject] No color fieldset found');
        return;
      }

      var existingLabels = colorFieldset.querySelectorAll('label.color-swatch[data-value]');
      var existingColors = new Set();
      existingLabels.forEach(function(l) { existingColors.add(l.dataset.value); });

      var missingColors = [...allColors].filter(function(c) { return !existingColors.has(c); });
      console.log('[Inject] Missing colors to inject:', missingColors);
      if (missingColors.length === 0) return;

      var refLabel = colorFieldset.querySelector('label.color-swatch[data-value]');
      var refInput = refLabel ? colorFieldset.querySelector('input[id="' + refLabel.getAttribute('for') + '"]') : null;
      if (!refLabel || !refInput) return;

      var inputName = refInput.name;

      missingColors.forEach(function(color) {
        var matchVariant = variants.find(function(v) {
          return v.opti color && v.featured_image;
        }) || variants.find(function(v) {
          return v.opti color;
        });
        var imgSrc = matchVariant && matchVariant.featured_image ? matchVariant.featured_image.src : null;

        var newInput = document.createElement('input');
        newInput.type = 'radio';
        var safeId = 'injected-color-' + color.replace(/[\s|\/'"]/g, '-');
        newInput.id = safeId;
        newInput.name = inputName;
        newInput.value = color;
        newInput.dataset.originalValue = color;
        newInput.dataset.productUrl = refInput.dataset.productUrl || '';
        newInput.dataset.opti || '';
        newInput.setAttribute('form', refInput.getAttribute('form') || '');
        newInput.hidden = true;

        var newLabel = document.createElement('label');
        newLabel.className = 'color-swatch';
        newLabel.setAttribute('for', safeId);
        newLabel.dataset.value = color;
        newLabel.hidden = true;

        if (imgSrc) {
          var img = document.createElement('img');
          img.src = imgSrc;
          img.alt = color;
          img.loading = 'lazy';
          img.style.display = 'block';
          img.style.maxWidth = '100%';
          img.style.maxHeight = '100%';
          img.style.margin = 'auto';
          newLabel.appendChild(img);
        }

        var hiddenSpan = document.createElement('span');
        hiddenSpan.className = 'visually-hidden label-unavailable';
        newLabel.appendChild(hiddenSpan);

        var colorDiv = colorFieldset.querySelector('.color-option');
        if (colorDiv) {
          colorDiv.appendChild(newInput);
          colorDiv.appendChild(newLabel);
        } else {
          colorFieldset.appendChild(newInput);
          colorFieldset.appendChild(newLabel);
        }

        console.log('[Inject] Injected:', color, '| img:', imgSrc || 'none');
      });

      document.querySelectorAll('variant-selects').forEach(function(vs) {
        vs.colorOpti label.color-swatch, fieldset[data-name="لون"] label.color-swatch')];
        console.log('[Inject] Refreshed colorOptions count:', vs.colorOptions.length);
        if (!skipFilter && !vs.dataset.combined) vs.filterOptions();
      });
    }

    window.injectMissingColorSwatches = injectMissingColorSwatches;

  })();

Diving Deeper: The Localization Mismatch

From what dorame described and the code provided, the core issue almost certainly lies in a mismatch of variant option values between the initial Liquid-rendered product data and the dynamically injected data from the Storefront API, compounded by localization.

Option Names vs. Option Values

Shopify products have option names (like "Color" or "Size") and option values (like "Red" or "Small"). While your theme correctly accounts for translated option names (e.g., checking for data-name="Color" or data-name="لون" in your variant-selects.js), the critical part is ensuring the option values themselves are consistent across languages, or that your code explicitly handles their translation.

The mapVariant function in your product-variant-picker.liquid pulls raw values from the Storefront API. By default, the Storefront API returns data in the shop's primary language unless you explicitly request a different language. If your product's primary language is English, these values will be "Red", "Blue", etc. However, if your Liquid template (which renders the initial product.variants and the existing swatches) is generating option values in Arabic (e.g., "أحمر", "أزرق"), then you've got a mismatch!

The Interplay of Liquid and Storefront API

Your window.ShopifyProduct.variants array is a blend: initially, it contains variants from {{ product.variants | json }} (Liquid context), and then it's augmented by mergeBatch with variants fetched via the Storefront API.

  • The Liquid-rendered variants might have option values localized to Arabic if your product data has been set up for translation and Liquid is outputting the translated values based on request.locale.iso_code.
  • The Storefront API variants will likely have option values in your store's primary language (e.g., English) unless the API request itself specifies a language.

This difference means that when your injectMissingColorSwatches function tries to compare allColors (derived from the potentially mixed window.ShopifyProduct.variants) with existingColors (derived from label.dataset.value on existing swatches, which might be Arabic), the !existingColors.has(c) check will fail. "Red" is not equal to "أحمر"!

Similarly, your filterOptions function in variant-selects.js also suffers from this. It relies on this.selectedOptions (which gets values from the UI, potentially localized) and then filters ShopifyProduct.variants using these values. If ShopifyProduct.variants contains non-localized values from the API, the filtering v.options[0] === g and v.options[1] === c will fail to find matches for Arabic selections.

Here's the variant-selects.js code for reference:

class VariantSelects extends HTMLElement {
  constructor() {
    super();
    this.variant = this.variant ?? JSON.parse(this.querySelector('variant-selects [data-selected-variant]')?.innerHTML);

    this.sizeOpti label, fieldset[data-name="مقاس"] label')];
    this.colorOpti label, fieldset[data-name="لون"] label')];
  }

  connectedCallback() {
    const section = this.closest('section');
    const productInfo = this.closest('product-info');
    const selectBtn = productInfo?.querySelector('.btn-select-size');
    const sizePopup = productInfo?.querySelector('.size-secection');
    const closeBtn = productInfo?.querySelector('.close-size');

    // Restore last-picked size
    if (window.sizeSelected) {
      console.log(window.sizeSelected, "window.sizeSelected");
      const field = this.querySelector(
        `fieldset[data-name="Size"] input[value="${window.sizeSelected}"],
         fieldset[data-name="مقاس"] input[value="${window.sizeSelected}"]`
      );
      if (field) field.checked = true;
    }

    // Clone blocks for mobile
    this.linkedTo = [...productInfo.querySelectorAll('[data-linked-to]')];
    this.linkedTo.forEach(block => {
      const toCl
      let name = block.dataset.linkedTo;
      if (htmlEl.lang === "ar" && htmlEl.dir === "rtl") {
        if (name === 'لون' || name === 'Color') {
          name = 'color';
        }
      } else {
        name = name.toLowerCase();
      }
      this[`${name}Options`] = [
        ...(this[`${name}Options`] || []),
        ...toClone.querySelectorAll('label')
      ];
      toClone.querySelectorAll('[id]').forEach(el => {
        el.id += 'cloned';
        el.name += 'cloned';
      });
      block.innerHTML = '';
      [...toClone.childNodes].reverse().forEach(n => toClone.append(n));
      block.appendChild(toClone);
    });

    // ✅ Assign loader early — showLoader() in the change handler needs it
    this.loader = productInfo.querySelector('.loader');

    const _selectedVariantData = JSON.parse(
      this.querySelector('[data-selected-variant]')?.innerHTML || 'null'
    );
    const _pendingColor = _selectedVariantData?.options?.[1];

    const runInitWithColorRestore = () => {
      if (!this.dataset.combined) {
        // ✅ skipFilter=true — we handle filterOptions ourselves after re-cloning
        window.injectMissingColorSwatches?.(true);

        // ✅ Re-clone mobile color fieldset AFTER injection so it includes the new swatches
        this.linkedTo?.forEach(linkedBlock => {
          const fieldsetName = linkedBlock.dataset.linkedTo;
          if (fieldsetName !== 'Color' && fieldsetName !== 'لون') return;
          const sourceFieldset = this.querySelector(`fieldset[data-name="${fieldsetName}"]`);
          if (!sourceFieldset) return;
          const toCl
          toClone.querySelectorAll('[id]').forEach(el => {
            el.id += 'cloned';
            el.name += 'cloned';
          });
          linkedBlock.innerHTML = '';
          [...toClone.childNodes].reverse().forEach(n => toClone.append(n));
          linkedBlock.appendChild(toClone);
        });

        // Restore checked color before filterOptions runs
        if (_pendingColor) {
          const colorInput = this.querySelector(
            `fieldset[data-name="Color"] input[value="${_pendingColor}"],
             fieldset[data-name="لون"] input[value="${_pendingColor}"]`
          );
          if (colorInput && !colorInput.checked) {
            colorInput.checked = true;
          }
        }

        this.filterOptions();
      }

      // ✅ Section only becomes visible AFTER correct variant state is fully applied — no flicker
      if (!section.classList.contains('show')) {
        section.classList.add('show');
        window.scrollTo({ top: 0, behavior: 'smooth' });
      }
      this.removeLoader();
      this.applyFallback();
      requestAnimationFrame(() => {
        const submitBtn = this.closest('product-info')?.querySelector('button.product-form__submit');
        if (submitBtn) submitBtn.style.opacity = '1';
      });
    };

    if (window.ShopifyProduct._loading) {
      document.addEventListener('shopify:variants:loaded', runInitWithColorRestore, { once: true });
    } else {
      runInitWithColorRestore();
    }

    // Update size label if already selected
    if (window.sizeSelected && selectBtn) {
      selectBtn.textC
      selectBtn.classList.add('selected');
    }

    // Size popup toggle
    if (selectBtn && !selectBtn.classList.contains('bound')) {
      selectBtn.addEventListener('click', () => {
        sizePopup.style.display = 'block';
      });
      selectBtn.classList.add('bound');
    }
    if (closeBtn && !closeBtn.classList.contains('bound')) {
      closeBtn.addEventListener('click', () => {
        sizePopup.style.display = 'none';
      });
      closeBtn.classList.add('bound');
    }

    this.addEventListener('change', event => {
      const t = this.getInputForEventTarget(event.target);
      const fs = t.closest('fieldset');
      const name = fs?.dataset.name;

      if (name === 'Gender' || name === 'جنس') {
        window.sizeSelected = '';
        this.querySelectorAll('fieldset[data-name="Size"] input[type="radio"], fieldset[data-name="مقاس"] input[type="radio"]').forEach(r => r.checked = false);
        if (selectBtn) {
          if (htmlEl.lang === "ar" && htmlEl.dir === "rtl") {
            selectBtn.textC;
          } else {
            selectBtn.textC;
          }
          selectBtn.classList.remove('selected');
        }
        sizePopup.style.display = 'none';
        section.classList.remove('show');
        this.showLoader();
      }

      if (name === 'Color' || name === 'لون') {
        window.sizeSelected = '';
        section.classList.remove('show');
        window.removeColorLoader?.();
      }

      if (name === 'Size' || name === 'مقاس') {
        const headerATC = document.querySelector('.product-form-btn');
        const submitBton = document.querySelector('.product-form-btn button[type="submit"]');
        const spinLoad = document.querySelector('.spin-load');
        const spinner = document.querySelector(".product-form-btn .loading__spinner");
        if (submitBton) {
          submitBton.classList.add("hidden");
          spinLoad.classList.remove("hidden");
          spinner?.classList.remove("hidden");
          headerATC.style.textAlign = 'center';
          setTimeout(() => {
            headerATC.style.textAlign = '';
            submitBton.classList.remove("hidden");
            spinLoad.classList.add("hidden");
            spinner?.classList.add("hidden");
          }, 1200);
        }

        if (!t.classList.contains("disabled")) {
          window.sizeSelected = t.value;
          if (selectBtn) {
            selectBtn.textC
            selectBtn.classList.add('selected');
          }
          sizePopup.style.display = 'none';
        } else {
          window.sizeSelected = '';
          if (selectBtn) {
            if (htmlEl.lang === "ar" && htmlEl.dir === "rtl") {
              selectBtn.textC;
            } else {
              selectBtn.textC;
            }
            selectBtn.classList.remove('selected');
          }
        }

        if (t.classList.contains("disabled")) {
          document.body.classList.add("loader-active");
          waitForElementAndClick(".gw-button-widget, .gw-float-widget");
        }

        function waitForElementAndClick(selector, maxAttempts = 10, interval = 200) {
          let attempts = 0;
          const checkAndClick = () => {
            const element = document.querySelector(selector);
            if (element && typeof element.click === 'function') {
              try {
                element.click();
                document.body.classList.remove("loader-active");
                return true;
              } catch (error) {
                console.warn("Click failed, retrying...", error);
              }
            }
            if (attempts < maxAttempts) {
              attempts++;
              setTimeout(checkAndClick, interval);
            } else {
              console.error("Failed to find or click the element after maximum attempts.");
            }
          };
          checkAndClick();
        }
      }

      // ✅ KEY FIX: if variants are still loading, wait then re-run filterOptions
      const runFilter = () => {
        if (!this.dataset.combined) this.filterOptions();
        this.applyFallback();
      };

      if (window.ShopifyProduct._loading) {
        document.addEventListener('shopify:variants:loaded', runFilter, { once: true });
      } else {
        runFilter();
      }

      this.updateSelectionMetadata(event);
      publish(PUB_SUB_EVENTS.optionValueSelectionChange, {
        data: {
          event,
          target: t,
          selectedOptionValues: this.selectedOptionValues,
          selectedOptions: this.selectedOptions,
        }
      });
    });

    // Observer for stock fallback
    const desktopBtn = productInfo.querySelector('button.product-form__submit');
    if (desktopBtn) new MutationObserver(() => {
      let { Gender, Color } = this.selectedOptions;
      let g = Gender || this.selectedOptions["جنس"];
      let c = Color  || this.selectedOptions["لون"];
      const inStock = (window.ShopifyProduct?.variants || []).filter(v => v.options[0] === g && v.options[1] === c && v.available);
      if (inStock.length > 0 && desktopBtn.disabled) this.applyFallback();
    }).observe(desktopBtn, { attributes: true, childList: true, subtree: true });

    document.querySelectorAll('form.add-to-cart-form button').forEach(btn => {
      new MutationObserver(() => {
        let { Gender, Color } = this.selectedOptions;
        let g = Gender || this.selectedOptions["جنس"];
        let c = Color  || this.selectedOptions["لون"];
        const inStock = (window.ShopifyProduct?.variants || []).filter(v => v.options[0] === g && v.options[1] === c && v.available);
        if (inStock.length > 0 && btn.disabled) this.applyFallback();
      }).observe(btn, { attributes: true, childList: true, subtree: true });
    });
  }

  filterOpti = true) => {
    let { Gender, Color } = this.selectedOptions;
    let g = Gender || this.selectedOptions["جنس"];
    let c = Color  || this.selectedOptions["لون"];
    const byG = ShopifyProduct.variants.filter(v => v.options[0] === g);

    if (hide) {
      const colors = [...new Set(byG.map(v => v.options[1]))];
      const productInfo = this.closest('product-info');

      // ✅ Query BOTH original fieldset labels AND cloned mobile fieldset labels
      this.colorOpti
        ...this.querySelectorAll('fieldset[data-name="Color"] label.color-swatch, fieldset[data-name="لون"] label.color-swatch'),
        ...(productInfo?.querySelectorAll('[data-linked-to="Color"] label.color-swatch, [data-linked-to="لون"] label.color-swatch') || [])
      ];
      this.colorOptions.forEach(el => el.hidden = !colors.includes(el.dataset.value));

      const sizes = [...new Set(byG.filter(v => v.options[1] === c).map(v => v.options[2]))];

      // ✅ Same for sizes
      this.sizeOpti
        ...this.querySelectorAll('fieldset[data-name="Size"] label, fieldset[data-name="مقاس"] label'),
        ...(productInfo?.querySelectorAll('[data-linked-to="Size"] label, [data-linked-to="مقاس"] label') || [])
      ];
      this.sizeOptions.forEach(el => el.hidden = !sizes.includes(el.dataset.value));
    }
    return true;
  }

  applyFallback() {
    const variants = window.ShopifyProduct?.variants || [];
    let { Gender, Color } = this.selectedOptions;
    let g = Gender || this.selectedOptions["جنس"];
    let c = Color  || this.selectedOptions["لون"];
    if (!g || !c) return;

    const inStock = variants.filter(v => v.options[0] === g && v.options[1] === c && v.available);
    const fallback = inStock[0] || null;

    const root = this.closest('product-info');
    if (root) {
      const desktopInput = root.querySelector('input.product-variant-id');
      const desktopBtn = root.querySelector('button.product-form__submit');
      if (desktopInput && desktopBtn) {
        if (fallback) {
          desktopInput.value = fallback.id;
          desktopInput.disabled = false;
          desktopBtn.disabled = false;
          if (htmlEl.lang === "ar" && htmlEl.dir === "rtl") {
            desktopBtn.querySelector('span').textC;
          }
          else {
            desktopBtn.querySelector('span').textC;
          }
        } else {
          desktopInput.value = '';
          desktopInput.disabled = true;
          desktopBtn.disabled = true;
          if (htmlEl.lang === "ar" && htmlEl.dir === "rtl") {
            desktopBtn.querySelector('span').textC;
          }
          else {
            desktopBtn.querySelector('span').textC;
          }
        }
      }
    }

    document.querySelectorAll('form.add-to-cart-form').forEach(form => {
      const input = form.querySelector('input.product-variant-id');
      const btn = form.querySelector('button');
      if (!input || !btn) return;
      if (fallback) {
        input.value = fallback.id;
        input.disabled = false;
        btn.disabled = false;
        if (htmlEl.lang === "ar" && htmlEl.dir === "rtl") {
          btn.textC;
        }
        else {
          btn.textC;
        }
      } else {
        input.value = '';
        input.disabled = true;
        btn.disabled = true;
        if (htmlEl.lang === "ar" && htmlEl.dir === "rtl") {
          btn.textC;
        }
        else {
          btn.textC;
        }
      }
    });
  }

  updateSelectionMetadata({ target }) {
    const { value, tagName } = target;
    if (tagName === 'SELECT' && target.selectedOptions.length) {
      target.querySelectorAll('option').forEach(o => o.selected = false);
      target.selectedOptions[0].selected = true;
      const sw = target.closest('.product-form__input').querySelector('[data-selected-value]>.swatch');
      const v = target.selectedOptions[0].dataset.optionSwatchValue;
      if (sw) {
        sw.style.setProperty('--swatch--background', v || 'unset');
        sw.classList.toggle('swatch--unavailable', !v);
      }
    } else if (tagName === 'INPUT' && target.type === 'radio') {
      const disp = target.closest('.product-form__input')?.querySelector('[data-selected-value]');
      if (disp) disp.textC
    }
  }

  showLoader() {
    this.loader?.classList.add('active');
  }

  removeLoader() {
    this.loader?.classList.remove('active');
  }

  getInputForEventTarget(t) {
    return t.tagName === 'SELECT' ? t.selectedOptions[0] : t;
  }

  get selectedOptionValues() {
    return Array.from(this.querySelectorAll('select option:checked, fieldset input:checked')).map(el => el.dataset.optionValueId);
  }

  get selectedOptions() {
    return Array.from(this.querySelectorAll('select option:checked, fieldset input:checked')).reduce((a, el) => {
      a[el.closest('fieldset').dataset.name] = el.value;
      return a;
    }, {});
  }
}

customElements.define('variant-selects', VariantSelects);

Actionable Steps for Resolution

Okay, so how do we fix this? The goal is to ensure that all parts of your system – Liquid, custom JavaScript, and Storefront API – are speaking the same language when it comes to variant option values, or that you have a translation layer in place.

  1. Standardize Option Values (The Golden Rule):

    The most robust solution is to keep your variant option values (e.g., "Red", "Small") consistent across all languages in your Shopify product data. Only translate the display labels for these options. Shopify's native translation features are designed for this. Your custom code should then always refer to these canonical, untranslated values. The UI would display "أحمر" but the underlying data-value on the swatch and the v.option2 in your JavaScript would still be "Red".

    To implement this, you'd modify how your Liquid outputs data-value for swatches. Instead of option.selected_value directly, you might need to use a metafield or a specific translation key that maps back to the canonical value.

  2. Localize Your Storefront API Calls:

    If you absolutely must have translated option values returned by the API (which complicates matching unless you have a mapping), you need to tell the Storefront API which language you want. You can do this by adding an Accept-Language header to your API requests. For example:

    fetch('https://' + SHOP_DOMAIN + '/api/2024-01/graphql.json', {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        'X-Shopify-Storefront-Access-Token': STOREFRONT_TOKEN,
        'Accept-Language': htmlEl.lang // assuming htmlEl.lang holds 'ar' or 'en'
      },
      body: buildQuery(cursor)
    })
    

    However, this can get complex quickly if your initial product.variants from Liquid are in a different language than what the API returns. The first solution (standardized values) is generally preferred for simplicity.

  3. Implement a Robust Value Mapping (If Standardization Isn't Possible):

    If you cannot standardize option values to a single language, you'll need a JavaScript-based mapping layer. You could build a lookup object (e.g., {'Red': 'أحمر', 'Blue': 'أزرق'}) that translates values from one language to another, or vice versa, before comparison. This would need to be applied in your injectMissingColorSwatches and filterOptions functions to normalize values before comparison.

  4. Debugging Your Data Flow:

    Your browser's developer console is your best friend here. In both English and Arabic modes:

    • Inspect window.ShopifyProduct.variants: Check the option1, option2, option3, and options arrays for consistency in values. Are they "Red" or "أحمر"?
    • Inspect the HTML: Look at your swatch elements and their data-value attributes. Are they "Red" or "أحمر"?
    • Trace the values: Add console.log() statements within your injectMissingColorSwatches and filterOptions functions to see the exact values of g, c, allColors, existingColors, and v.options at critical comparison points. This will quickly reveal where the mismatch is occurring.

It sounds like you're very close to cracking this, dorame! The fact that English works perfectly tells us the logic is sound, but the data flowing through that logic needs to be consistent across languages. Focusing on how your option values are represented and compared between your Liquid template and your Storefront API calls will be key. Keep those console logs handy, and you'll pinpoint the exact translation hiccup in no time. Once that data consistency is achieved, your swatches should be shining brightly in Arabic too!

Share:

Start with the tools

Explore migration tools

See options, compare methods, and pick the path that fits your store.

Explore migration tools