Add Shopify variant options selector with circular radio buttons
All checks were successful
Deploy / Deploy to Production (push) Successful in 7s

- Implement dynamic option rendering from Shopify variant data
- Generate circular radio buttons for product variants (sizes, colors, etc.)
- Disable add-to-cart button until option is selected
- Display "Choisissez une option" text when option required
- Update button text and enable on option selection
- Add is-selected class to chosen option
- Handle disabled state for out-of-stock variants
- Restore btn__default button style with icon and text structure
- Add chooseOption translation key in FR/EN

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
isUnknown 2026-01-16 12:15:28 +01:00
parent 0c8cc5000c
commit 4987c4830f
5 changed files with 109 additions and 69 deletions

View file

@ -55,9 +55,8 @@
renderPrice(product);
renderDetails(product, isEnglish);
renderImages(product, isEnglish);
renderVariants(product);
renderOptions(product);
setupAddToCart(product);
renderStock(product);
}
function renderTitle(product, isEnglish) {
@ -113,42 +112,78 @@
}
}
function renderVariants(product) {
function renderOptions(product) {
if (product.variants.edges.length <= 1) return;
const variantsContainer = document.querySelector("[data-product-variants]");
const variantSelector = document.querySelector("[data-variant-selector]");
const firstVariant = product.variants.edges[0].node;
if (!firstVariant.selectedOptions || firstVariant.selectedOptions.length === 0) return;
if (!variantsContainer || !variantSelector) return;
const mainOption = firstVariant.selectedOptions[0];
const optionValues = new Set();
variantsContainer.style.display = "block";
variantSelector.innerHTML = product.variants.edges
.map((edge) => {
const variant = edge.node;
const variantId = variant.id.replace(
"gid://shopify/ProductVariant/",
""
);
const price = parseFloat(variant.price.amount).toFixed(2) + "€";
const availability = variant.availableForSale
? ""
: " (Rupture de stock)";
return `<option value="${variantId}" ${
!variant.availableForSale ? "disabled" : ""
}>
${variant.title} - ${price}${availability}
</option>`;
})
.join("");
variantSelector.addEventListener("change", (e) => {
const addToCartBtn = document.querySelector("[data-shopify-add-to-cart]");
if (addToCartBtn) {
addToCartBtn.dataset.variantId = e.target.value;
product.variants.edges.forEach(edge => {
const variant = edge.node;
if (variant.selectedOptions && variant.selectedOptions[0]) {
optionValues.add(variant.selectedOptions[0].value);
}
});
if (optionValues.size <= 1) return;
const optionsContainer = document.querySelector("[data-product-options]");
const optionsList = document.querySelector("[data-product-options-list]");
if (!optionsContainer || !optionsList) return;
const optionName = mainOption.name;
const optionSlug = optionName.toLowerCase().replace(/\s+/g, '-');
optionsList.innerHTML = Array.from(optionValues).map((value) => {
const uniqueId = `${optionSlug}-${value.toLowerCase().replace(/\s+/g, '-')}`;
const variant = product.variants.edges.find(e =>
e.node.selectedOptions && e.node.selectedOptions[0]?.value === value
)?.node;
const isAvailable = variant?.availableForSale || false;
return `
<li>
<input
type="radio"
id="${uniqueId}"
name="${optionSlug}"
value="${value}"
data-variant-id="${variant ? variant.id.replace('gid://shopify/ProductVariant/', '') : ''}"
${!isAvailable ? 'disabled' : ''}
/>
<label for="${uniqueId}">${value}</label>
</li>
`;
}).join('');
optionsContainer.style.display = 'block';
const radios = optionsList.querySelectorAll('input[type="radio"]');
const addToCartBtn = document.querySelector("[data-shopify-add-to-cart]");
const buttonText = addToCartBtn?.querySelector('[data-button-text]');
radios.forEach(radio => {
radio.addEventListener('change', function() {
const variantId = this.dataset.variantId;
if (addToCartBtn) {
addToCartBtn.dataset.variantId = variantId;
addToCartBtn.removeAttribute('disabled');
}
if (buttonText) {
buttonText.textContent = addToCartBtn.dataset.defaultText || 'Ajouter au panier';
}
const allLi = optionsList.querySelectorAll('li');
allLi.forEach(li => li.classList.remove('is-selected'));
this.closest('li').classList.add('is-selected');
});
});
}
function setupAddToCart(product) {
@ -158,31 +193,35 @@
const productId = product.id.replace("gid://shopify/Product/", "");
addToCartBtn.dataset.productId = productId;
const firstAvailableVariant = product.variants.edges.find(
(e) => e.node.availableForSale
);
if (firstAvailableVariant) {
const variantId = firstAvailableVariant.node.id.replace(
"gid://shopify/ProductVariant/",
""
);
addToCartBtn.dataset.variantId = variantId;
}
}
const hasMultipleVariants = product.variants.edges.length > 1;
const firstVariant = product.variants.edges[0]?.node;
const hasOptions = firstVariant?.selectedOptions && firstVariant.selectedOptions.length > 0;
function renderStock(product) {
const stockEl = document.querySelector("[data-product-stock]");
if (!stockEl) return;
const uniqueOptions = new Set();
product.variants.edges.forEach(edge => {
if (edge.node.selectedOptions && edge.node.selectedOptions[0]) {
uniqueOptions.add(edge.node.selectedOptions[0].value);
}
});
const hasMultipleOptions = uniqueOptions.size > 1;
const addToCartBtn = document.querySelector("[data-shopify-add-to-cart]");
if (product.availableForSale) {
stockEl.textContent = addToCartBtn?.dataset.textInStock || "En stock";
stockEl.classList.add("in-stock");
if (hasMultipleVariants && hasOptions && hasMultipleOptions) {
addToCartBtn.setAttribute('disabled', 'disabled');
const buttonText = addToCartBtn.querySelector('[data-button-text]');
if (buttonText) {
buttonText.textContent = addToCartBtn.dataset.textChooseOption || 'Choisissez une option';
}
} else {
stockEl.textContent =
addToCartBtn?.dataset.textOutOfStock || "Rupture de stock";
stockEl.classList.add("out-of-stock");
const firstAvailableVariant = product.variants.edges.find(
(e) => e.node.availableForSale
);
if (firstAvailableVariant) {
const variantId = firstAvailableVariant.node.id.replace(
"gid://shopify/ProductVariant/",
""
);
addToCartBtn.dataset.variantId = variantId;
}
}
}
})();

View file

@ -30,6 +30,7 @@ return [
'loading' => 'Loading...',
'productNotFound' => 'Product not found',
'selectVariant' => 'Select',
'chooseOption' => 'Choose an option',
// Blueprints - Home
'home.title' => 'Home',

View file

@ -30,6 +30,7 @@ return [
'loading' => 'Chargement...',
'productNotFound' => 'Produit non trouvé',
'selectVariant' => 'Choisir',
'chooseOption' => 'Choisissez une option',
// Blueprints - Home
'home.title' => 'Accueil',

View file

@ -1,25 +1,20 @@
<div class="product-purchase">
<div class="product-stock-info">
<p data-product-stock class="stock-status"></p>
</div>
<div class="product-variants" data-product-variants style="display: none;">
<label for="variant-select"><?= t('selectVariant') ?></label>
<select id="variant-select" data-variant-selector></select>
</div>
<div class="add-to-cart">
<button
class="btn-add-to-cart"
class="btn__default"
data-shopify-add-to-cart
data-product-id=""
data-variant-id=""
data-text-add="<?= t('addToCart') ?>"
data-default-text="<?= t('addToCart') ?>"
data-text-choose-option="<?= t('chooseOption') ?>"
data-text-adding="<?= t('addingToCart') ?>"
data-text-added="<?= t('addedToCart') ?>"
data-text-error="<?= t('errorAddToCart') ?>"
data-text-out-of-stock="<?= t('outOfStock') ?>"
data-text-in-stock="<?= t('inStock') ?>"
>
<?= t('addToCart') ?>
<span class="icon">
<svg clip-rule="evenodd" fill-rule="evenodd" stroke-linejoin="round" stroke-miterlimit="2" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path d="m14.523 18.787s4.501-4.505 6.255-6.26c.146-.146.219-.338.219-.53s-.073-.383-.219-.53c-1.753-1.754-6.255-6.258-6.255-6.258-.144-.145-.334-.217-.524-.217-.193 0-.385.074-.532.221-.293.292-.295.766-.004 1.056l4.978 4.978h-14.692c-.414 0-.75.336-.75.75s.336.75.75.75h14.692l-4.979 4.979c-.289.289-.286.762.006 1.054.148.148.341.222.533.222.19 0 .378-.072.522-.215z" fill-rule="nonzero" />
</svg>
</span>
<div class="txt" data-button-text><?= t('addToCart') ?></div>
</button>
</div>

View file

@ -27,6 +27,10 @@ snippet('header', ['title' => $page->title(), 'template' => 'shop']);
<div class="details" data-product-details></div>
<div class="product-options" data-product-options style="display: none;">
<ul class="product-options__list" data-product-options-list></ul>
</div>
<?php snippet('buy-button') ?>
</div>