You've already forked wc-licensed-product
- Fix OrderLicenseController to use isLicensedProduct() for consistent product type detection - Fixed expected licenses calculation for variable product orders - Fixed manual license generation from admin order page for variable products - Remove debug logging from all source files (PHP and JavaScript) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
502 lines
20 KiB
JavaScript
502 lines
20 KiB
JavaScript
/**
|
||
* WooCommerce Checkout Blocks Integration
|
||
*
|
||
* Adds domain fields to the checkout block for licensed products.
|
||
* Supports single domain mode (legacy) and multi-domain mode (per quantity).
|
||
*
|
||
* @package WcLicensedProduct
|
||
*/
|
||
|
||
(function () {
|
||
'use strict';
|
||
|
||
// Check dependencies
|
||
if (typeof wc === 'undefined' ||
|
||
typeof wc.blocksCheckout === 'undefined' ||
|
||
typeof wc.wcSettings === 'undefined') {
|
||
return;
|
||
}
|
||
|
||
const { getSetting } = wc.wcSettings;
|
||
const { createElement, useState, useEffect, useCallback } = wp.element;
|
||
const { TextControl } = wp.components;
|
||
const { __ } = wp.i18n;
|
||
|
||
// Get available exports from blocksCheckout
|
||
const { ExperimentalOrderMeta, extensionCartUpdate } = wc.blocksCheckout;
|
||
|
||
// Debounce function for API updates
|
||
function debounce(func, wait) {
|
||
let timeout;
|
||
return function executedFunction(...args) {
|
||
const later = () => {
|
||
clearTimeout(timeout);
|
||
func(...args);
|
||
};
|
||
clearTimeout(timeout);
|
||
timeout = setTimeout(later, wait);
|
||
};
|
||
}
|
||
|
||
// Get settings from PHP
|
||
const settings = getSetting('wc-licensed-product_data', {});
|
||
|
||
// Check if we have licensed products
|
||
if (!settings.hasLicensedProducts) {
|
||
return;
|
||
}
|
||
|
||
/**
|
||
* Validate domain format
|
||
*/
|
||
function isValidDomain(domain) {
|
||
if (!domain || domain.length > 255) return false;
|
||
const pattern = /^([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}$/;
|
||
return pattern.test(domain);
|
||
}
|
||
|
||
/**
|
||
* Normalize domain
|
||
*/
|
||
function normalizeDomain(domain) {
|
||
return domain.toLowerCase().trim()
|
||
.replace(/^https?:\/\//, '')
|
||
.replace(/^www\./, '')
|
||
.replace(/\/.*$/, '');
|
||
}
|
||
|
||
/**
|
||
* Single Domain Component
|
||
*/
|
||
const SingleDomainField = () => {
|
||
const [domain, setDomain] = useState('');
|
||
const [error, setError] = useState('');
|
||
|
||
// Debounced API update function
|
||
const updateStoreApi = useCallback(
|
||
debounce((normalizedDomain) => {
|
||
if (extensionCartUpdate) {
|
||
extensionCartUpdate({
|
||
namespace: 'wc-licensed-product',
|
||
data: {
|
||
licensed_product_domain: normalizedDomain,
|
||
},
|
||
}).catch(err => {
|
||
console.error('[WCLP] Store API update error:', err);
|
||
});
|
||
}
|
||
}, 500),
|
||
[]
|
||
);
|
||
|
||
const handleChange = (value) => {
|
||
const normalized = normalizeDomain(value);
|
||
setDomain(normalized);
|
||
|
||
if (normalized && !isValidDomain(normalized)) {
|
||
setError(settings.validationError || __('Please enter a valid domain.', 'wc-licensed-product'));
|
||
} else {
|
||
setError('');
|
||
// Update Store API when valid
|
||
updateStoreApi(normalized);
|
||
}
|
||
|
||
// Store in hidden input for form submission (fallback)
|
||
const hiddenInput = document.getElementById('wclp-domain-hidden');
|
||
if (hiddenInput) {
|
||
hiddenInput.value = normalized;
|
||
}
|
||
};
|
||
|
||
return createElement(
|
||
'div',
|
||
{
|
||
className: 'wc-block-components-licensed-product-domain',
|
||
style: {
|
||
padding: '16px',
|
||
backgroundColor: '#f0f0f0',
|
||
borderRadius: '4px',
|
||
marginBottom: '16px',
|
||
}
|
||
},
|
||
createElement('h4', { style: { marginTop: 0, marginBottom: '8px' } },
|
||
settings.sectionTitle || __('License Domain', 'wc-licensed-product')
|
||
),
|
||
createElement('p', { style: { marginBottom: '12px', color: '#666', fontSize: '0.9em' } },
|
||
settings.fieldDescription || __('Enter the domain where you will use the license.', 'wc-licensed-product')
|
||
),
|
||
createElement(TextControl, {
|
||
label: settings.singleDomainLabel || __('Domain', 'wc-licensed-product'),
|
||
value: domain,
|
||
onChange: handleChange,
|
||
placeholder: settings.fieldPlaceholder || 'example.com',
|
||
help: error || '',
|
||
className: error ? 'has-error' : '',
|
||
}),
|
||
createElement('input', {
|
||
type: 'hidden',
|
||
id: 'wclp-domain-hidden',
|
||
name: 'wclp_license_domain',
|
||
value: domain,
|
||
})
|
||
);
|
||
};
|
||
|
||
/**
|
||
* Get unique key for product (handles variations)
|
||
*/
|
||
function getProductKey(product) {
|
||
if (product.variation_id && product.variation_id > 0) {
|
||
return `${product.product_id}_${product.variation_id}`;
|
||
}
|
||
return String(product.product_id);
|
||
}
|
||
|
||
/**
|
||
* Multi-Domain Component
|
||
*/
|
||
const MultiDomainFields = () => {
|
||
const products = settings.licensedProducts || [];
|
||
const [domains, setDomains] = useState(() => {
|
||
const init = {};
|
||
products.forEach(p => {
|
||
const key = getProductKey(p);
|
||
init[key] = Array(p.quantity).fill('');
|
||
});
|
||
return init;
|
||
});
|
||
const [errors, setErrors] = useState({});
|
||
|
||
// Debounced API update function
|
||
const updateStoreApi = useCallback(
|
||
debounce((domainsData) => {
|
||
if (extensionCartUpdate) {
|
||
extensionCartUpdate({
|
||
namespace: 'wc-licensed-product',
|
||
data: {
|
||
licensed_product_domains: domainsData,
|
||
},
|
||
}).catch(err => {
|
||
console.error('[WCLP] Store API update error:', err);
|
||
});
|
||
}
|
||
}, 500),
|
||
[]
|
||
);
|
||
|
||
if (!products.length) {
|
||
return null;
|
||
}
|
||
|
||
const handleChange = (productKey, index, value) => {
|
||
const normalized = normalizeDomain(value);
|
||
const newDomains = { ...domains };
|
||
if (!newDomains[productKey]) newDomains[productKey] = [];
|
||
newDomains[productKey] = [...newDomains[productKey]];
|
||
newDomains[productKey][index] = normalized;
|
||
setDomains(newDomains);
|
||
|
||
// Validate
|
||
const key = `${productKey}_${index}`;
|
||
const newErrors = { ...errors };
|
||
if (normalized && !isValidDomain(normalized)) {
|
||
newErrors[key] = settings.validationError || __('Please enter a valid domain.', 'wc-licensed-product');
|
||
} else {
|
||
delete newErrors[key];
|
||
}
|
||
|
||
// Check for duplicates within same product/variation
|
||
const productDomains = newDomains[productKey].filter(d => d);
|
||
const uniqueDomains = new Set(productDomains.map(d => normalizeDomain(d)));
|
||
if (productDomains.length !== uniqueDomains.size) {
|
||
const seen = new Set();
|
||
newDomains[productKey].forEach((d, idx) => {
|
||
const normalizedD = normalizeDomain(d);
|
||
const dupKey = `${productKey}_${idx}`;
|
||
if (normalizedD && seen.has(normalizedD)) {
|
||
newErrors[dupKey] = settings.duplicateError || __('Each license requires a unique domain.', 'wc-licensed-product');
|
||
} else if (normalizedD) {
|
||
seen.add(normalizedD);
|
||
}
|
||
});
|
||
}
|
||
|
||
setErrors(newErrors);
|
||
|
||
// Build domain data for Store API
|
||
const data = products.map(p => {
|
||
const pKey = getProductKey(p);
|
||
const doms = newDomains[pKey] || [];
|
||
const entry = {
|
||
product_id: p.product_id,
|
||
domains: doms.filter(d => d),
|
||
};
|
||
if (p.variation_id && p.variation_id > 0) {
|
||
entry.variation_id = p.variation_id;
|
||
}
|
||
return entry;
|
||
}).filter(item => item.domains.length > 0);
|
||
|
||
// Update Store API
|
||
updateStoreApi(data);
|
||
|
||
// Update hidden field (fallback)
|
||
const hiddenInput = document.getElementById('wclp-domains-hidden');
|
||
if (hiddenInput) {
|
||
hiddenInput.value = JSON.stringify(data);
|
||
}
|
||
};
|
||
|
||
return createElement(
|
||
'div',
|
||
{
|
||
className: 'wc-block-components-licensed-product-domains',
|
||
style: {
|
||
padding: '16px',
|
||
backgroundColor: '#f0f0f0',
|
||
borderRadius: '4px',
|
||
marginBottom: '16px',
|
||
}
|
||
},
|
||
createElement('h4', { style: { marginTop: 0, marginBottom: '8px' } },
|
||
settings.sectionTitle || __('License Domains', 'wc-licensed-product')
|
||
),
|
||
createElement('p', { style: { marginBottom: '12px', color: '#666', fontSize: '0.9em' } },
|
||
settings.fieldDescription || __('Enter a unique domain for each license.', 'wc-licensed-product')
|
||
),
|
||
products.map(product => {
|
||
const productKey = getProductKey(product);
|
||
const durationLabel = product.duration_label || '';
|
||
const displayName = durationLabel
|
||
? `${product.name} (${durationLabel})`
|
||
: product.name;
|
||
|
||
return createElement(
|
||
'div',
|
||
{
|
||
key: productKey,
|
||
style: {
|
||
marginBottom: '16px',
|
||
padding: '12px',
|
||
backgroundColor: '#fff',
|
||
borderRadius: '4px',
|
||
}
|
||
},
|
||
createElement('strong', { style: { display: 'block', marginBottom: '8px' } },
|
||
displayName + (product.quantity > 1 ? ` ×${product.quantity}` : '')
|
||
),
|
||
Array.from({ length: product.quantity }, (_, i) => {
|
||
const key = `${productKey}_${i}`;
|
||
return createElement(
|
||
'div',
|
||
{ key: i, style: { marginBottom: '8px' } },
|
||
createElement(TextControl, {
|
||
label: (settings.licenseLabel || __('License %d:', 'wc-licensed-product')).replace('%d', i + 1),
|
||
value: domains[productKey]?.[i] || '',
|
||
onChange: (val) => handleChange(productKey, i, val),
|
||
placeholder: settings.fieldPlaceholder || 'example.com',
|
||
help: errors[key] || '',
|
||
})
|
||
);
|
||
})
|
||
);
|
||
}),
|
||
createElement('input', {
|
||
type: 'hidden',
|
||
id: 'wclp-domains-hidden',
|
||
name: 'wclp_license_domains',
|
||
value: '',
|
||
})
|
||
);
|
||
};
|
||
|
||
/**
|
||
* Main License Domains Block
|
||
*/
|
||
const LicenseDomainsBlock = () => {
|
||
if (settings.isMultiDomainEnabled) {
|
||
return createElement(MultiDomainFields);
|
||
}
|
||
return createElement(SingleDomainField);
|
||
};
|
||
|
||
// Register using ExperimentalOrderMeta slot
|
||
if (ExperimentalOrderMeta) {
|
||
const { registerPlugin } = wp.plugins || {};
|
||
|
||
if (registerPlugin) {
|
||
registerPlugin('wc-licensed-product-domain-fields', {
|
||
render: () => {
|
||
return createElement(
|
||
ExperimentalOrderMeta,
|
||
{},
|
||
createElement(LicenseDomainsBlock)
|
||
);
|
||
},
|
||
scope: 'woocommerce-checkout',
|
||
});
|
||
}
|
||
}
|
||
|
||
// Fallback: inject into DOM directly if React approach fails
|
||
setTimeout(function() {
|
||
const existingComponent = document.querySelector('.wc-block-components-licensed-product-domain, .wc-block-components-licensed-product-domains');
|
||
if (existingComponent) {
|
||
return;
|
||
}
|
||
|
||
const checkoutForm = document.querySelector('.wc-block-checkout, .wc-block-checkout__form, form.checkout');
|
||
if (!checkoutForm) {
|
||
return;
|
||
}
|
||
|
||
const contactInfo = document.querySelector('.wc-block-checkout__contact-fields, .wp-block-woocommerce-checkout-contact-information-block');
|
||
const paymentMethods = document.querySelector('.wc-block-checkout__payment-method, .wp-block-woocommerce-checkout-payment-block');
|
||
|
||
let insertionPoint = contactInfo || paymentMethods;
|
||
if (!insertionPoint) {
|
||
insertionPoint = checkoutForm.querySelector('.wc-block-components-form');
|
||
}
|
||
|
||
if (!insertionPoint) {
|
||
return;
|
||
}
|
||
|
||
const container = document.createElement('div');
|
||
container.id = 'wclp-domain-fields-container';
|
||
container.className = 'wc-block-components-licensed-product-wrapper';
|
||
container.style.cssText = 'margin: 20px 0; padding: 16px; background: #f0f0f0; border-radius: 4px;';
|
||
|
||
if (settings.isMultiDomainEnabled && settings.licensedProducts) {
|
||
container.innerHTML = `
|
||
<h4 style="margin: 0 0 8px 0;">${settings.sectionTitle || 'License Domains'}</h4>
|
||
<p style="margin-bottom: 12px; color: #666; font-size: 0.9em;">
|
||
${settings.fieldDescription || 'Enter a unique domain for each license.'}
|
||
</p>
|
||
${settings.licensedProducts.map(product => {
|
||
const productKey = product.variation_id && product.variation_id > 0
|
||
? `${product.product_id}_${product.variation_id}`
|
||
: product.product_id;
|
||
const durationLabel = product.duration_label || '';
|
||
const displayName = durationLabel
|
||
? `${product.name} (${durationLabel})`
|
||
: product.name;
|
||
|
||
return `
|
||
<div style="margin-bottom: 16px; padding: 12px; background: #fff; border-radius: 4px;">
|
||
<strong style="display: block; margin-bottom: 8px;">
|
||
${displayName}${product.quantity > 1 ? ` ×${product.quantity}` : ''}
|
||
</strong>
|
||
${Array.from({ length: product.quantity }, (_, i) => `
|
||
<div style="margin-bottom: 8px;">
|
||
<label style="display: block; margin-bottom: 4px;">
|
||
${(settings.licenseLabel || 'License %d:').replace('%d', i + 1)}
|
||
</label>
|
||
<input type="text"
|
||
name="licensed_domains[${productKey}][${i}]"
|
||
placeholder="${settings.fieldPlaceholder || 'example.com'}"
|
||
style="width: 100%; padding: 8px; border: 1px solid #ccc; border-radius: 4px;"
|
||
/>
|
||
${product.variation_id && product.variation_id > 0 ? `
|
||
<input type="hidden"
|
||
name="licensed_variation_ids[${productKey}]"
|
||
value="${product.variation_id}"
|
||
/>
|
||
` : ''}
|
||
</div>
|
||
`).join('')}
|
||
</div>
|
||
`}).join('')}
|
||
`;
|
||
} else {
|
||
container.innerHTML = `
|
||
<h4 style="margin: 0 0 8px 0;">${settings.sectionTitle || 'License Domain'}</h4>
|
||
<p style="margin-bottom: 12px; color: #666; font-size: 0.9em;">
|
||
${settings.fieldDescription || 'Enter the domain where you will use the license.'}
|
||
</p>
|
||
<div style="margin-bottom: 8px;">
|
||
<label style="display: block; margin-bottom: 4px;">
|
||
${settings.singleDomainLabel || 'Domain'}
|
||
</label>
|
||
<input type="text"
|
||
name="licensed_product_domain"
|
||
placeholder="${settings.fieldPlaceholder || 'example.com'}"
|
||
style="width: 100%; padding: 8px; border: 1px solid #ccc; border-radius: 4px;"
|
||
/>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
if (contactInfo) {
|
||
contactInfo.parentNode.insertBefore(container, contactInfo.nextSibling);
|
||
} else if (paymentMethods) {
|
||
paymentMethods.parentNode.insertBefore(container, paymentMethods);
|
||
} else {
|
||
insertionPoint.appendChild(container);
|
||
}
|
||
|
||
// Add event listeners to sync with Store API
|
||
const debouncedUpdate = debounce(function() {
|
||
if (!extensionCartUpdate) {
|
||
return;
|
||
}
|
||
|
||
if (settings.isMultiDomainEnabled && settings.licensedProducts) {
|
||
// Collect multi-domain data
|
||
const domainsData = settings.licensedProducts.map(function(product) {
|
||
const productKey = product.variation_id && product.variation_id > 0
|
||
? product.product_id + '_' + product.variation_id
|
||
: String(product.product_id);
|
||
|
||
const domains = [];
|
||
for (let i = 0; i < product.quantity; i++) {
|
||
const input = container.querySelector('input[name="licensed_domains[' + productKey + '][' + i + ']"]');
|
||
if (input && input.value.trim()) {
|
||
domains.push(normalizeDomain(input.value));
|
||
}
|
||
}
|
||
|
||
const entry = {
|
||
product_id: product.product_id,
|
||
domains: domains,
|
||
};
|
||
if (product.variation_id && product.variation_id > 0) {
|
||
entry.variation_id = product.variation_id;
|
||
}
|
||
return entry;
|
||
}).filter(function(item) { return item.domains.length > 0; });
|
||
|
||
extensionCartUpdate({
|
||
namespace: 'wc-licensed-product',
|
||
data: {
|
||
licensed_product_domains: domainsData,
|
||
},
|
||
}).catch(function(err) {
|
||
console.error('[WCLP] Store API update error:', err);
|
||
});
|
||
} else {
|
||
// Single domain
|
||
const input = container.querySelector('input[name="licensed_product_domain"]');
|
||
if (input) {
|
||
const domain = normalizeDomain(input.value);
|
||
extensionCartUpdate({
|
||
namespace: 'wc-licensed-product',
|
||
data: {
|
||
licensed_product_domain: domain,
|
||
},
|
||
}).catch(function(err) {
|
||
console.error('[WCLP] Store API update error:', err);
|
||
});
|
||
}
|
||
}
|
||
}, 500);
|
||
|
||
// Attach event listeners to all domain inputs
|
||
container.querySelectorAll('input[type="text"]').forEach(function(input) {
|
||
input.addEventListener('input', debouncedUpdate);
|
||
input.addEventListener('change', debouncedUpdate);
|
||
});
|
||
}, 2000);
|
||
|
||
})();
|