You've already forked wc-licensed-product
Implement multi-domain licensing for v0.5.0
- Add multi-domain checkout support for WooCommerce Blocks - Fix domain field rendering using ExperimentalOrderMeta slot - Add DOM injection fallback for checkout field rendering - Update translations with new multi-domain strings (de_CH) - Update email templates for grouped license display - Refactor account page to group licenses by product/order Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,7 +1,8 @@
|
||||
/**
|
||||
* WooCommerce Checkout Blocks Integration
|
||||
*
|
||||
* Adds a domain field to the checkout block for licensed products.
|
||||
* Adds domain fields to the checkout block for licensed products.
|
||||
* Supports single domain mode (legacy) and multi-domain mode (per quantity).
|
||||
*
|
||||
* @package WcLicensedProduct
|
||||
*/
|
||||
@@ -9,92 +10,333 @@
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
const { registerCheckoutBlock } = wc.blocksCheckout;
|
||||
const { createElement, useState, useEffect } = wp.element;
|
||||
// Check dependencies
|
||||
if (typeof wc === 'undefined' ||
|
||||
typeof wc.blocksCheckout === 'undefined' ||
|
||||
typeof wc.wcSettings === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
const { getSetting } = wc.wcSettings;
|
||||
const { createElement, useState } = wp.element;
|
||||
const { TextControl } = wp.components;
|
||||
const { __ } = wp.i18n;
|
||||
const { extensionCartUpdate } = wc.blocksCheckout;
|
||||
const { getSetting } = wc.wcSettings;
|
||||
|
||||
// Get settings passed from PHP
|
||||
// Get available exports from blocksCheckout
|
||||
const { ExperimentalOrderMeta } = wc.blocksCheckout;
|
||||
|
||||
// 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;
|
||||
}
|
||||
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 (remove protocol and www)
|
||||
* Normalize domain
|
||||
*/
|
||||
function normalizeDomain(domain) {
|
||||
let normalized = domain.toLowerCase().trim();
|
||||
normalized = normalized.replace(/^https?:\/\//, '');
|
||||
normalized = normalized.replace(/^www\./, '');
|
||||
normalized = normalized.replace(/\/.*$/, '');
|
||||
return normalized;
|
||||
return domain.toLowerCase().trim()
|
||||
.replace(/^https?:\/\//, '')
|
||||
.replace(/^www\./, '')
|
||||
.replace(/\/.*$/, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* License Domain Block Component
|
||||
* Single Domain Component
|
||||
*/
|
||||
const LicenseDomainBlock = ({ checkoutExtensionData, extensions }) => {
|
||||
const SingleDomainField = () => {
|
||||
const [domain, setDomain] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const { setExtensionData } = checkoutExtensionData;
|
||||
|
||||
// Only show if cart has licensed products
|
||||
if (!settings.hasLicensedProducts) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const handleChange = (value) => {
|
||||
const normalized = normalizeDomain(value);
|
||||
setDomain(normalized);
|
||||
|
||||
// Validate
|
||||
if (normalized && !isValidDomain(normalized)) {
|
||||
setError(settings.validationError || __('Please enter a valid domain.', 'wc-licensed-product'));
|
||||
} else {
|
||||
setError('');
|
||||
}
|
||||
|
||||
// Update extension data for server-side processing
|
||||
setExtensionData('wc-licensed-product', 'licensed_product_domain', normalized);
|
||||
// Store in hidden input for form submission
|
||||
const hiddenInput = document.getElementById('wclp-domain-hidden');
|
||||
if (hiddenInput) {
|
||||
hiddenInput.value = normalized;
|
||||
}
|
||||
};
|
||||
|
||||
return createElement(
|
||||
'div',
|
||||
{ className: 'wc-block-components-licensed-product-domain' },
|
||||
createElement(
|
||||
'h3',
|
||||
{ className: 'wc-block-components-title' },
|
||||
{
|
||||
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.fieldLabel || __('Domain for License Activation', 'wc-licensed-product'),
|
||||
label: settings.singleDomainLabel || __('Domain', 'wc-licensed-product'),
|
||||
value: domain,
|
||||
onChange: handleChange,
|
||||
placeholder: settings.fieldPlaceholder || 'example.com',
|
||||
help: error || settings.fieldDescription || __('Enter the domain where you will use this license.', 'wc-licensed-product'),
|
||||
help: error || '',
|
||||
className: error ? 'has-error' : '',
|
||||
required: true,
|
||||
}),
|
||||
createElement('input', {
|
||||
type: 'hidden',
|
||||
id: 'wclp-domain-hidden',
|
||||
name: 'wclp_license_domain',
|
||||
value: domain,
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
// Register the checkout block
|
||||
registerCheckoutBlock({
|
||||
metadata: {
|
||||
name: 'wc-licensed-product/domain-field',
|
||||
parent: ['woocommerce/checkout-contact-information-block'],
|
||||
},
|
||||
component: LicenseDomainBlock,
|
||||
});
|
||||
/**
|
||||
* Multi-Domain Component
|
||||
*/
|
||||
const MultiDomainFields = () => {
|
||||
const products = settings.licensedProducts || [];
|
||||
const [domains, setDomains] = useState(() => {
|
||||
const init = {};
|
||||
products.forEach(p => {
|
||||
init[p.product_id] = Array(p.quantity).fill('');
|
||||
});
|
||||
return init;
|
||||
});
|
||||
const [errors, setErrors] = useState({});
|
||||
|
||||
if (!products.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const handleChange = (productId, index, value) => {
|
||||
const normalized = normalizeDomain(value);
|
||||
const newDomains = { ...domains };
|
||||
if (!newDomains[productId]) newDomains[productId] = [];
|
||||
newDomains[productId] = [...newDomains[productId]];
|
||||
newDomains[productId][index] = normalized;
|
||||
setDomains(newDomains);
|
||||
|
||||
// Validate
|
||||
const key = `${productId}_${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
|
||||
const productDomains = newDomains[productId].filter(d => d);
|
||||
const uniqueDomains = new Set(productDomains.map(d => normalizeDomain(d)));
|
||||
if (productDomains.length !== uniqueDomains.size) {
|
||||
const seen = new Set();
|
||||
newDomains[productId].forEach((d, idx) => {
|
||||
const normalizedD = normalizeDomain(d);
|
||||
const dupKey = `${productId}_${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);
|
||||
|
||||
// Update hidden field
|
||||
const data = Object.entries(newDomains).map(([pid, doms]) => ({
|
||||
product_id: parseInt(pid, 10),
|
||||
domains: doms.filter(d => d),
|
||||
})).filter(item => item.domains.length > 0);
|
||||
|
||||
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 => createElement(
|
||||
'div',
|
||||
{
|
||||
key: product.product_id,
|
||||
style: {
|
||||
marginBottom: '16px',
|
||||
padding: '12px',
|
||||
backgroundColor: '#fff',
|
||||
borderRadius: '4px',
|
||||
}
|
||||
},
|
||||
createElement('strong', { style: { display: 'block', marginBottom: '8px' } },
|
||||
product.name + (product.quantity > 1 ? ` (×${product.quantity})` : '')
|
||||
),
|
||||
Array.from({ length: product.quantity }, (_, i) => {
|
||||
const key = `${product.product_id}_${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[product.product_id]?.[i] || '',
|
||||
onChange: (val) => handleChange(product.product_id, 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: () => 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 => `
|
||||
<div style="margin-bottom: 16px; padding: 12px; background: #fff; border-radius: 4px;">
|
||||
<strong style="display: block; margin-bottom: 8px;">
|
||||
${product.name}${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[${product.product_id}][${i}]"
|
||||
placeholder="${settings.fieldPlaceholder || 'example.com'}"
|
||||
style="width: 100%; padding: 8px; border: 1px solid #ccc; border-radius: 4px;"
|
||||
/>
|
||||
</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);
|
||||
}
|
||||
}, 2000);
|
||||
|
||||
})();
|
||||
|
||||
@@ -25,6 +25,9 @@
|
||||
$(document).on('click', '.wclp-modal-close, .wclp-modal-cancel, .wclp-modal-overlay', this.closeTransferModal.bind(this));
|
||||
$(document).on('submit', '#wclp-transfer-form', this.submitTransfer.bind(this));
|
||||
|
||||
// Older versions toggle
|
||||
$(document).on('click', '.older-versions-toggle', this.toggleOlderVersions);
|
||||
|
||||
// Close modal on escape key
|
||||
$(document).on('keyup', function(e) {
|
||||
if (e.key === 'Escape') {
|
||||
@@ -33,6 +36,20 @@
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Toggle older versions visibility
|
||||
*/
|
||||
toggleOlderVersions: function(e) {
|
||||
e.preventDefault();
|
||||
|
||||
var $btn = $(this);
|
||||
var $list = $btn.siblings('.older-versions-list');
|
||||
var isExpanded = $btn.attr('aria-expanded') === 'true';
|
||||
|
||||
$btn.attr('aria-expanded', !isExpanded);
|
||||
$list.slideToggle(200);
|
||||
},
|
||||
|
||||
/**
|
||||
* Copy license key to clipboard
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user