Add variant collections system and enhance ProductVariant with weight/stock tracking
This commit introduces a comprehensive variant management system and enhances the existing ProductVariant model with per-variant weight overrides and stock tracking, integrated across Admin Panel and TeleBot. Features Added: - Variant Collections: Reusable variant templates (e.g., "Standard Sizes") - Admin UI for managing variant collections (CRUD operations) - Dynamic variant editor with JavaScript-based UI - Per-variant weight and weight unit overrides - Per-variant stock level tracking - SalesLedger model for financial tracking ProductVariant Enhancements: - Added Weight (decimal, nullable) field for variant-specific weights - Added WeightUnit (enum, nullable) field for variant-specific units - Maintains backward compatibility with product-level weights TeleBot Integration: - Enhanced variant selection UI to display stock levels - Shows weight information with proper unit conversion (µg, g, oz, lb, ml, L) - Compact button format: "Medium (15 in stock, 350g)" - Real-time stock availability display Database Migrations: - 20250928014850_AddVariantCollectionsAndSalesLedger - 20250928155814_AddWeightToProductVariants Technical Changes: - Updated Product model to support VariantCollectionId and VariantsJson - Extended ProductService with variant collection operations - Enhanced OrderService to handle variant-specific pricing and weights - Updated LittleShop.Client DTOs to match server models - Added JavaScript dynamic variant form builder Files Modified: 15 Files Added: 17 Lines Changed: ~2000 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
537
LittleShop/wwwroot/js/product-variants.js
Normal file
537
LittleShop/wwwroot/js/product-variants.js
Normal file
@@ -0,0 +1,537 @@
|
||||
// Product Variants Management
|
||||
// Handles dynamic variant input fields based on selected VariantCollection
|
||||
|
||||
class ProductVariantsManager {
|
||||
constructor() {
|
||||
this.variantCollectionSelect = document.getElementById('VariantCollectionId');
|
||||
this.variantsJsonTextarea = document.getElementById('VariantsJson');
|
||||
this.dynamicFieldsContainer = document.getElementById('dynamic-variant-fields');
|
||||
this.advancedToggle = document.getElementById('toggle-advanced-variants');
|
||||
this.advancedSection = document.getElementById('advanced-variant-section');
|
||||
this.productStockInput = document.getElementById('StockQuantity');
|
||||
this.productWeightUnitSelect = document.getElementById('WeightUnit');
|
||||
|
||||
if (!this.variantCollectionSelect || !this.variantsJsonTextarea || !this.dynamicFieldsContainer) {
|
||||
console.error('ProductVariantsManager: Required elements not found');
|
||||
return;
|
||||
}
|
||||
|
||||
this.currentProperties = [];
|
||||
this.init();
|
||||
}
|
||||
|
||||
init() {
|
||||
// Listen for variant collection selection changes
|
||||
this.variantCollectionSelect.addEventListener('change', (e) => {
|
||||
this.handleVariantCollectionChange(e.target.value);
|
||||
});
|
||||
|
||||
// Toggle advanced JSON editing
|
||||
if (this.advancedToggle) {
|
||||
this.advancedToggle.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
this.toggleAdvancedMode();
|
||||
});
|
||||
}
|
||||
|
||||
// Load existing variant data if in edit mode
|
||||
if (this.variantCollectionSelect.value) {
|
||||
this.handleVariantCollectionChange(this.variantCollectionSelect.value);
|
||||
} else if (this.variantsJsonTextarea.value && this.variantsJsonTextarea.value.trim() !== '') {
|
||||
// No collection selected but has JSON - show advanced mode
|
||||
this.showAdvancedMode();
|
||||
}
|
||||
|
||||
// Form submission handler
|
||||
const form = this.variantCollectionSelect.closest('form');
|
||||
if (form) {
|
||||
form.addEventListener('submit', (e) => {
|
||||
this.serializeVariantsToJson();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async handleVariantCollectionChange(collectionId) {
|
||||
if (!collectionId || collectionId === '') {
|
||||
this.clearDynamicFields();
|
||||
this.currentProperties = [];
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`/Admin/Products/GetVariantCollection?id=${collectionId}`);
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
const collection = await response.json();
|
||||
console.log('Loaded variant collection:', collection);
|
||||
console.log('PropertiesJson raw:', collection.propertiesJson);
|
||||
|
||||
let propertyDefinitions = {};
|
||||
try {
|
||||
const parsed = JSON.parse(collection.propertiesJson || '{}');
|
||||
console.log('Parsed properties:', parsed);
|
||||
console.log('Parsed type:', typeof parsed, 'Is array:', Array.isArray(parsed));
|
||||
|
||||
if (typeof parsed === 'object' && !Array.isArray(parsed) && parsed !== null) {
|
||||
propertyDefinitions = parsed;
|
||||
} else if (Array.isArray(parsed)) {
|
||||
const converted = {};
|
||||
parsed.forEach(item => {
|
||||
if (typeof item === 'string') {
|
||||
converted[item] = null;
|
||||
} else if (typeof item === 'object' && item.name) {
|
||||
converted[item.name] = item.values || null;
|
||||
}
|
||||
});
|
||||
propertyDefinitions = converted;
|
||||
} else {
|
||||
console.error('Unexpected PropertiesJson format:', parsed);
|
||||
propertyDefinitions = {};
|
||||
}
|
||||
|
||||
console.log('Property definitions:', propertyDefinitions);
|
||||
} catch (err) {
|
||||
console.error('Error parsing PropertiesJson:', err);
|
||||
propertyDefinitions = {};
|
||||
}
|
||||
|
||||
this.currentProperties = propertyDefinitions;
|
||||
|
||||
let existingVariants = [];
|
||||
if (this.variantsJsonTextarea.value && this.variantsJsonTextarea.value.trim() !== '') {
|
||||
try {
|
||||
existingVariants = JSON.parse(this.variantsJsonTextarea.value);
|
||||
if (!Array.isArray(existingVariants)) {
|
||||
existingVariants = [];
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('Could not parse existing variants JSON:', err);
|
||||
existingVariants = [];
|
||||
}
|
||||
}
|
||||
|
||||
this.generateDynamicFields(propertyDefinitions, existingVariants);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error loading variant collection:', error);
|
||||
alert('Failed to load variant collection properties. Please try again.');
|
||||
}
|
||||
}
|
||||
|
||||
generateDynamicFields(propertyDefinitions, existingVariants = []) {
|
||||
this.clearDynamicFields();
|
||||
|
||||
if (!propertyDefinitions || Object.keys(propertyDefinitions).length === 0) {
|
||||
this.dynamicFieldsContainer.innerHTML = '<p class="text-muted"><i class="fas fa-info-circle"></i> This variant collection has no properties defined.</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
let variantsToDisplay = existingVariants;
|
||||
let isAutoGenerated = false;
|
||||
|
||||
if (!variantsToDisplay || variantsToDisplay.length === 0) {
|
||||
variantsToDisplay = this.generateCombinations(propertyDefinitions);
|
||||
isAutoGenerated = true;
|
||||
console.log('Auto-generated combinations:', variantsToDisplay);
|
||||
} else {
|
||||
console.log('Using existing variants:', variantsToDisplay);
|
||||
}
|
||||
|
||||
if (variantsToDisplay.length === 0) {
|
||||
this.dynamicFieldsContainer.innerHTML = '<p class="text-muted"><i class="fas fa-info-circle"></i> No variant combinations to generate. Please configure property values in the variant collection.</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
const variantRows = variantsToDisplay.map((combo, index) => this.createVariantRow(combo, index, propertyDefinitions)).join('');
|
||||
|
||||
const alertMessage = isAutoGenerated
|
||||
? `<strong>Auto-Generated Variants:</strong> ${variantsToDisplay.length} variant combination(s) created. Fill in optional details for each variant.`
|
||||
: `<strong>Existing Variants:</strong> ${variantsToDisplay.length} variant(s) loaded. You can modify or add new variants below.`;
|
||||
|
||||
this.dynamicFieldsContainer.innerHTML = `
|
||||
<div class="alert alert-info mb-3">
|
||||
<i class="fas fa-lightbulb"></i> ${alertMessage}
|
||||
</div>
|
||||
<div id="variant-rows-container">
|
||||
${variantRows}
|
||||
</div>
|
||||
<button type="button" class="btn btn-sm btn-outline-primary mt-2" id="add-variant-row">
|
||||
<i class="fas fa-plus"></i> Add Custom Variant
|
||||
</button>
|
||||
`;
|
||||
|
||||
document.getElementById('add-variant-row')?.addEventListener('click', () => {
|
||||
this.addCustomVariantRow(propertyDefinitions);
|
||||
});
|
||||
|
||||
this.attachVariantRowEventHandlers();
|
||||
}
|
||||
|
||||
attachVariantRowEventHandlers() {
|
||||
document.querySelectorAll('.remove-variant-row').forEach(btn => {
|
||||
btn.addEventListener('click', (e) => {
|
||||
const row = e.target.closest('.variant-row');
|
||||
if (row) {
|
||||
row.remove();
|
||||
this.updateStockCalculation();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
document.querySelectorAll('.toggle-variant-details').forEach(btn => {
|
||||
btn.addEventListener('click', (e) => {
|
||||
const rowIndex = btn.dataset.row;
|
||||
const detailsSection = document.querySelector(`.variant-details-section[data-row="${rowIndex}"]`);
|
||||
const icon = btn.querySelector('i');
|
||||
|
||||
if (detailsSection) {
|
||||
if (detailsSection.style.display === 'none') {
|
||||
detailsSection.style.display = 'block';
|
||||
icon.classList.remove('fa-chevron-down');
|
||||
icon.classList.add('fa-chevron-up');
|
||||
} else {
|
||||
detailsSection.style.display = 'none';
|
||||
icon.classList.remove('fa-chevron-up');
|
||||
icon.classList.add('fa-chevron-down');
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
document.querySelectorAll('.variant-stock').forEach(input => {
|
||||
input.addEventListener('input', () => {
|
||||
this.updateStockCalculation();
|
||||
});
|
||||
});
|
||||
|
||||
this.updateStockCalculation();
|
||||
}
|
||||
|
||||
updateStockCalculation() {
|
||||
if (!this.productStockInput) return;
|
||||
|
||||
const variantStockInputs = document.querySelectorAll('.variant-stock');
|
||||
let totalVariantStock = 0;
|
||||
let hasVariantStock = false;
|
||||
|
||||
variantStockInputs.forEach(input => {
|
||||
const value = input.value.trim();
|
||||
if (value && !isNaN(value)) {
|
||||
totalVariantStock += parseInt(value);
|
||||
hasVariantStock = true;
|
||||
}
|
||||
});
|
||||
|
||||
const stockLabel = this.productStockInput.closest('.mb-3')?.querySelector('label');
|
||||
let warningIcon = document.getElementById('stock-variant-warning');
|
||||
|
||||
if (hasVariantStock) {
|
||||
this.productStockInput.disabled = true;
|
||||
this.productStockInput.value = totalVariantStock;
|
||||
this.productStockInput.classList.add('bg-light');
|
||||
|
||||
if (!warningIcon && stockLabel) {
|
||||
warningIcon = document.createElement('i');
|
||||
warningIcon.id = 'stock-variant-warning';
|
||||
warningIcon.className = 'fas fa-calculator text-info ms-2';
|
||||
warningIcon.style.cursor = 'pointer';
|
||||
warningIcon.setAttribute('data-bs-toggle', 'tooltip');
|
||||
warningIcon.setAttribute('data-bs-placement', 'top');
|
||||
warningIcon.setAttribute('title', `Calculated from variants: ${totalVariantStock} units total. Modify variant stock quantities to change total.`);
|
||||
stockLabel.appendChild(warningIcon);
|
||||
|
||||
if (typeof bootstrap !== 'undefined') {
|
||||
new bootstrap.Tooltip(warningIcon);
|
||||
}
|
||||
} else if (warningIcon) {
|
||||
warningIcon.setAttribute('title', `Calculated from variants: ${totalVariantStock} units total. Modify variant stock quantities to change total.`);
|
||||
if (typeof bootstrap !== 'undefined') {
|
||||
const tooltip = bootstrap.Tooltip.getInstance(warningIcon);
|
||||
if (tooltip) {
|
||||
tooltip.dispose();
|
||||
new bootstrap.Tooltip(warningIcon);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
this.productStockInput.disabled = false;
|
||||
this.productStockInput.classList.remove('bg-light');
|
||||
|
||||
if (warningIcon) {
|
||||
const tooltip = bootstrap?.Tooltip?.getInstance(warningIcon);
|
||||
if (tooltip) {
|
||||
tooltip.dispose();
|
||||
}
|
||||
warningIcon.remove();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
generateCombinations(propertyDefinitions) {
|
||||
const properties = Object.keys(propertyDefinitions);
|
||||
if (properties.length === 0) return [];
|
||||
|
||||
const propertyValues = properties.map(prop => {
|
||||
const values = propertyDefinitions[prop];
|
||||
return {
|
||||
name: prop,
|
||||
values: Array.isArray(values) && values.length > 0 ? values : [null]
|
||||
};
|
||||
});
|
||||
|
||||
const combinations = [];
|
||||
const generate = (index, current) => {
|
||||
if (index === propertyValues.length) {
|
||||
combinations.push({...current});
|
||||
return;
|
||||
}
|
||||
|
||||
const prop = propertyValues[index];
|
||||
for (const value of prop.values) {
|
||||
current[prop.name] = value;
|
||||
generate(index + 1, current);
|
||||
}
|
||||
};
|
||||
|
||||
generate(0, {});
|
||||
return combinations;
|
||||
}
|
||||
|
||||
createVariantRow(variantData, index, propertyDefinitions) {
|
||||
const propertyFields = Object.entries(propertyDefinitions).map(([propName, propValues]) => {
|
||||
const currentValue = variantData[propName];
|
||||
let fieldHtml;
|
||||
|
||||
if (Array.isArray(propValues) && propValues.length > 0) {
|
||||
const options = propValues.map(val =>
|
||||
`<option value="${val}" ${val === currentValue ? 'selected' : ''}>${val}</option>`
|
||||
).join('');
|
||||
fieldHtml = `
|
||||
<div class="col-md-3 mb-2">
|
||||
<label class="form-label small">${propName}</label>
|
||||
<select class="form-select form-select-sm variant-property" data-property="${propName}" data-row="${index}">
|
||||
${options}
|
||||
</select>
|
||||
</div>
|
||||
`;
|
||||
} else {
|
||||
fieldHtml = `
|
||||
<div class="col-md-3 mb-2">
|
||||
<label class="form-label small">${propName}</label>
|
||||
<input type="text" class="form-control form-control-sm variant-property"
|
||||
data-property="${propName}" data-row="${index}"
|
||||
value="${currentValue || ''}" placeholder="Enter ${propName}" />
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
return fieldHtml;
|
||||
}).join('');
|
||||
|
||||
const variantLabel = Object.entries(variantData)
|
||||
.filter(([k, v]) => v !== null && k !== 'Label' && k !== 'StockQty' && k !== 'Weight' && k !== 'WeightUnit')
|
||||
.map(([k, v]) => v)
|
||||
.join(' / ');
|
||||
|
||||
const existingLabel = variantData.Label || '';
|
||||
const existingStockQty = variantData.StockQty || '';
|
||||
const existingWeight = variantData.Weight || '';
|
||||
const existingWeightUnit = variantData.WeightUnit !== undefined ? variantData.WeightUnit : (this.productWeightUnitSelect ? this.productWeightUnitSelect.value : '0');
|
||||
|
||||
const weightUnitOptions = `
|
||||
<option value="0" ${existingWeightUnit == '0' ? 'selected' : ''}>Unit</option>
|
||||
<option value="1" ${existingWeightUnit == '1' ? 'selected' : ''}>Micrograms</option>
|
||||
<option value="2" ${existingWeightUnit == '2' ? 'selected' : ''}>Grams</option>
|
||||
<option value="3" ${existingWeightUnit == '3' ? 'selected' : ''}>Ounces</option>
|
||||
<option value="4" ${existingWeightUnit == '4' ? 'selected' : ''}>Pounds</option>
|
||||
<option value="5" ${existingWeightUnit == '5' ? 'selected' : ''}>Millilitres</option>
|
||||
<option value="6" ${existingWeightUnit == '6' ? 'selected' : ''}>Litres</option>
|
||||
`;
|
||||
|
||||
return `
|
||||
<div class="card mb-2 variant-row" data-row="${index}">
|
||||
<div class="card-body p-3">
|
||||
<div class="row align-items-center">
|
||||
<div class="col-md-1">
|
||||
<strong class="text-muted">#${index + 1}</strong>
|
||||
</div>
|
||||
<div class="col-md-10">
|
||||
<div class="row">
|
||||
${propertyFields}
|
||||
<div class="col-md-6 mb-2">
|
||||
<label class="form-label small">Label/Description <small class="text-muted">(optional)</small></label>
|
||||
<input type="text" class="form-control form-control-sm variant-label"
|
||||
data-row="${index}" value="${existingLabel}" placeholder="e.g., ${variantLabel}" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-2">
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary toggle-variant-details" data-row="${index}">
|
||||
<i class="fas fa-chevron-down"></i> Stock & Weight Details
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="variant-details-section mt-2" data-row="${index}" style="display: none;">
|
||||
<div class="row">
|
||||
<div class="col-md-4 mb-2">
|
||||
<label class="form-label small">Stock Quantity <small class="text-muted">(optional)</small></label>
|
||||
<input type="number" min="0" class="form-control form-control-sm variant-stock"
|
||||
data-row="${index}" value="${existingStockQty}" placeholder="Override stock qty" />
|
||||
<small class="form-text text-muted">Leave blank to use product stock</small>
|
||||
</div>
|
||||
<div class="col-md-4 mb-2">
|
||||
<label class="form-label small">Weight Override <small class="text-muted">(optional)</small></label>
|
||||
<input type="number" step="0.01" min="0" class="form-control form-control-sm variant-weight"
|
||||
data-row="${index}" value="${existingWeight}" placeholder="Override weight" />
|
||||
<small class="form-text text-muted">Leave blank to use product weight</small>
|
||||
</div>
|
||||
<div class="col-md-4 mb-2">
|
||||
<label class="form-label small">Weight Unit</label>
|
||||
<select class="form-select form-select-sm variant-weight-unit" data-row="${index}">
|
||||
${weightUnitOptions}
|
||||
</select>
|
||||
<small class="form-text text-muted">Defaults to product unit</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-1 text-end">
|
||||
<button type="button" class="btn btn-sm btn-outline-danger remove-variant-row" title="Remove this variant">
|
||||
<i class="fas fa-trash"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
addCustomVariantRow(propertyDefinitions) {
|
||||
const container = document.getElementById('variant-rows-container');
|
||||
if (!container) return;
|
||||
|
||||
const existingRows = container.querySelectorAll('.variant-row').length;
|
||||
const emptyVariant = {};
|
||||
Object.keys(propertyDefinitions).forEach(prop => {
|
||||
emptyVariant[prop] = null;
|
||||
});
|
||||
|
||||
const newRowHtml = this.createVariantRow(emptyVariant, existingRows, propertyDefinitions);
|
||||
container.insertAdjacentHTML('beforeend', newRowHtml);
|
||||
|
||||
this.attachVariantRowEventHandlers();
|
||||
}
|
||||
|
||||
populateDynamicFields(variantData) {
|
||||
if (!variantData || typeof variantData !== 'object') {
|
||||
return;
|
||||
}
|
||||
|
||||
Object.keys(variantData).forEach(key => {
|
||||
const input = document.getElementById(`variant-${key}`);
|
||||
if (input) {
|
||||
input.value = variantData[key];
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
clearDynamicFields() {
|
||||
this.dynamicFieldsContainer.innerHTML = '';
|
||||
}
|
||||
|
||||
serializeVariantsToJson() {
|
||||
if (!this.variantCollectionSelect.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
const variantRows = document.querySelectorAll('.variant-row');
|
||||
if (variantRows.length === 0) {
|
||||
this.variantsJsonTextarea.value = '';
|
||||
return;
|
||||
}
|
||||
|
||||
const allVariants = [];
|
||||
|
||||
variantRows.forEach((row, rowIndex) => {
|
||||
const variantData = {};
|
||||
let hasValues = false;
|
||||
|
||||
const propertyInputs = row.querySelectorAll('.variant-property');
|
||||
propertyInputs.forEach(input => {
|
||||
const property = input.dataset.property;
|
||||
const value = input.value.trim();
|
||||
|
||||
if (value) {
|
||||
variantData[property] = value;
|
||||
hasValues = true;
|
||||
}
|
||||
});
|
||||
|
||||
const labelInput = row.querySelector('.variant-label');
|
||||
if (labelInput && labelInput.value.trim()) {
|
||||
variantData['Label'] = labelInput.value.trim();
|
||||
hasValues = true;
|
||||
}
|
||||
|
||||
const stockInput = row.querySelector('.variant-stock');
|
||||
if (stockInput && stockInput.value.trim()) {
|
||||
variantData['StockQty'] = parseInt(stockInput.value.trim());
|
||||
hasValues = true;
|
||||
}
|
||||
|
||||
const weightInput = row.querySelector('.variant-weight');
|
||||
if (weightInput && weightInput.value.trim()) {
|
||||
variantData['Weight'] = parseFloat(weightInput.value.trim());
|
||||
hasValues = true;
|
||||
|
||||
const weightUnitSelect = row.querySelector('.variant-weight-unit');
|
||||
if (weightUnitSelect) {
|
||||
variantData['WeightUnit'] = parseInt(weightUnitSelect.value);
|
||||
}
|
||||
}
|
||||
|
||||
if (hasValues) {
|
||||
allVariants.push(variantData);
|
||||
}
|
||||
});
|
||||
|
||||
this.variantsJsonTextarea.value = allVariants.length > 0 ? JSON.stringify(allVariants) : '';
|
||||
console.log('Serialized variants:', this.variantsJsonTextarea.value);
|
||||
}
|
||||
|
||||
toggleAdvancedMode() {
|
||||
if (this.advancedSection.style.display === 'none') {
|
||||
this.showAdvancedMode();
|
||||
} else {
|
||||
this.hideAdvancedMode();
|
||||
}
|
||||
}
|
||||
|
||||
showAdvancedMode() {
|
||||
if (this.advancedSection) {
|
||||
this.advancedSection.style.display = 'block';
|
||||
if (this.advancedToggle) {
|
||||
this.advancedToggle.innerHTML = '<i class="fas fa-eye-slash"></i> Hide Advanced JSON Editor';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
hideAdvancedMode() {
|
||||
if (this.advancedSection) {
|
||||
this.advancedSection.style.display = 'none';
|
||||
if (this.advancedToggle) {
|
||||
this.advancedToggle.innerHTML = '<i class="fas fa-code"></i> Show Advanced JSON Editor';
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize on DOM ready
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
if (document.getElementById('VariantCollectionId')) {
|
||||
window.productVariantsManager = new ProductVariantsManager();
|
||||
console.log('ProductVariantsManager initialized');
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user