littleshop/LittleShop/wwwroot/js/product-variants.js
SysAdmin 8385612bcd Fix: Add Price field to variant collection editor
Added Price override input field to the JavaScript variant collection editor on the product Edit page.

**Changes:**
- Added Price input field (with £ symbol) in variant details section
- Updated serialization to save Price to VariantsJson
- Excluded Price from variant label generation
- Updated button text: "Price, Stock & Weight Details"

**Location:**
Product Edit > Variants Collection > Toggle Details > Price Override

Now variant prices can be set through BOTH methods:
1. Individual variant management (CreateVariant/EditVariant)
2. Bulk variant collection editor (product Edit page)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-03 18:45:13 +01:00

552 lines
24 KiB
JavaScript

// 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 !== 'Price' && 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> Price, 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">Price Override <small class="text-muted">(optional)</small></label>
<div class="input-group input-group-sm">
<span class="input-group-text">£</span>
<input type="number" step="0.01" min="0.01" class="form-control form-control-sm variant-price"
data-row="${index}" value="${variantData.Price || ''}" placeholder="Override price" />
</div>
<small class="form-text text-muted">Leave blank to use product price</small>
</div>
<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 priceInput = row.querySelector('.variant-price');
if (priceInput && priceInput.value.trim()) {
variantData['Price'] = parseFloat(priceInput.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');
}
});