diff --git a/assets/css/frontend.css b/assets/css/frontend.css index 20b0638..d7eeb07 100644 --- a/assets/css/frontend.css +++ b/assets/css/frontend.css @@ -37,13 +37,196 @@ color: #383d41; } -/* License Cards */ +/* License Packages */ .woocommerce-licenses { display: flex; flex-direction: column; gap: 1.5em; } +.license-package { + border: 1px solid #e5e5e5; + border-radius: 8px; + overflow: hidden; + background: #fff; +} + +.package-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 1em 1.5em; + background: #f8f9fa; + border-bottom: 1px solid #e5e5e5; +} + +.package-title { + display: flex; + flex-direction: column; + gap: 0.25em; +} + +.package-title h3 { + margin: 0; + font-size: 1.1em; +} + +.package-title h3 a { + color: inherit; + text-decoration: none; +} + +.package-title h3 a:hover { + text-decoration: underline; +} + +.package-order { + font-size: 0.85em; + color: #666; +} + +.package-order a { + color: #2271b1; + text-decoration: none; +} + +.package-order a:hover { + text-decoration: underline; +} + +.package-license-count { + font-size: 0.9em; + color: #666; + background: #e9ecef; + padding: 0.3em 0.8em; + border-radius: 12px; +} + +/* Package Licenses - Two Row Layout */ +.package-licenses { + padding: 0; +} + +.license-entry { + padding: 1em 1.5em; + border-bottom: 1px solid #e5e5e5; +} + +.license-entry:last-child { + border-bottom: none; +} + +.license-entry:hover { + background-color: #fafafa; +} + +.license-row-primary { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1em; + margin-bottom: 0.5em; +} + +.license-key-group { + display: flex; + align-items: center; + gap: 0.75em; + flex-shrink: 1; + min-width: 0; +} + +.license-entry code.license-key { + font-family: 'SF Mono', Monaco, Consolas, monospace; + background-color: #f5f5f5; + padding: 0.4em 0.75em; + border-radius: 4px; + font-size: 0.95em; + letter-spacing: 0.03em; + flex-shrink: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; +} + +.license-key-group .license-status { + flex-shrink: 0; +} + +.license-actions { + display: flex; + align-items: center; + gap: 0.5em; + flex-shrink: 0; +} + +.license-row-secondary { + display: flex; + align-items: center; + gap: 1.5em; + font-size: 0.9em; + color: #666; + flex-wrap: wrap; +} + +.license-meta-item { + display: inline-flex; + align-items: center; + gap: 0.35em; +} + +.license-meta-item .dashicons { + font-size: 14px; + width: 14px; + height: 14px; + color: #999; +} + +.license-domain { + color: #333; +} + +.license-expiry .lifetime { + color: #28a745; + font-weight: 500; +} + +/* Legacy table styles (kept for backwards compatibility) */ +.licenses-table { + width: 100%; + border-collapse: collapse; + font-size: 0.95em; +} + +.licenses-table th, +.licenses-table td { + padding: 0.75em 1em; + text-align: left; + border-bottom: 1px solid #e5e5e5; +} + +.licenses-table th { + font-weight: 600; + background-color: #fafafa; + font-size: 0.9em; + color: #555; +} + +.licenses-table code.license-key { + font-family: 'SF Mono', Monaco, Consolas, monospace; + background-color: #f5f5f5; + padding: 0.3em 0.6em; + border-radius: 4px; + font-size: 0.9em; + letter-spacing: 0.03em; +} + +.licenses-table .lifetime { + color: #28a745; + font-weight: 500; +} + +/* Legacy single card styles (kept for backwards compatibility) */ .license-card { border: 1px solid #e5e5e5; border-radius: 8px; @@ -184,12 +367,14 @@ } /* Download Section */ +.package-downloads, .license-downloads { padding: 1em 1.5em; background: #f8f9fa; border-top: 1px solid #e5e5e5; } +.package-downloads h4, .license-downloads h4 { margin: 0 0 0.75em 0; font-size: 0.95em; @@ -282,6 +467,71 @@ color: #666; } +/* Latest version badge */ +.download-version-badge { + display: inline-block; + padding: 0.15em 0.5em; + margin-left: 0.5em; + font-size: 0.75em; + font-weight: 600; + text-transform: uppercase; + background: #d4edda; + color: #155724; + border-radius: 3px; + vertical-align: middle; +} + +/* Older versions collapsible */ +.older-versions-section { + margin-top: 0.75em; + padding-top: 0.75em; + border-top: 1px dashed #ddd; +} + +.older-versions-toggle { + display: inline-flex; + align-items: center; + gap: 0.35em; + padding: 0.4em 0.75em; + background: transparent; + border: 1px solid #ddd; + border-radius: 4px; + font-size: 0.85em; + color: #666; + cursor: pointer; + transition: all 0.2s ease; +} + +.older-versions-toggle:hover { + background: #f5f5f5; + border-color: #ccc; + color: #333; +} + +.older-versions-toggle .dashicons { + font-size: 16px; + width: 16px; + height: 16px; + transition: transform 0.2s ease; +} + +.older-versions-toggle[aria-expanded="true"] .dashicons { + transform: rotate(180deg); +} + +.older-versions-list { + margin-top: 0.75em; + padding-left: 0; +} + +.older-versions-list .download-item { + opacity: 0.85; +} + +.older-versions-list .download-item:hover { + opacity: 1; +} + /* Domain Field */ #licensed-product-domain-field { margin-top: 2em; @@ -333,6 +583,52 @@ /* Responsive */ @media screen and (max-width: 768px) { + /* Package header responsive */ + .package-header { + flex-direction: column; + align-items: flex-start; + gap: 0.75em; + } + + .package-license-count { + align-self: flex-start; + } + + /* License entry responsive */ + .license-entry { + padding: 1em; + } + + .license-row-primary { + flex-direction: column; + align-items: flex-start; + gap: 0.75em; + } + + .license-key-group { + flex-direction: column; + align-items: flex-start; + gap: 0.5em; + width: 100%; + } + + .license-entry code.license-key { + font-size: 0.85em; + word-break: break-all; + white-space: normal; + } + + .license-actions { + align-self: flex-start; + } + + .license-row-secondary { + flex-direction: column; + align-items: flex-start; + gap: 0.5em; + } + + /* Legacy card layout responsive */ .license-header { flex-direction: column; align-items: flex-start; @@ -354,33 +650,44 @@ flex-wrap: wrap; } + /* Legacy table responsive */ .woocommerce-licenses-table, .woocommerce-licenses-table thead, .woocommerce-licenses-table tbody, .woocommerce-licenses-table th, .woocommerce-licenses-table td, - .woocommerce-licenses-table tr { + .woocommerce-licenses-table tr, + .licenses-table, + .licenses-table thead, + .licenses-table tbody, + .licenses-table th, + .licenses-table td, + .licenses-table tr { display: block; } - .woocommerce-licenses-table thead tr { + .woocommerce-licenses-table thead tr, + .licenses-table thead tr { position: absolute; top: -9999px; left: -9999px; } - .woocommerce-licenses-table tr { + .woocommerce-licenses-table tr, + .licenses-table tr { border: 1px solid #e5e5e5; margin-bottom: 1em; } - .woocommerce-licenses-table td { + .woocommerce-licenses-table td, + .licenses-table td { border: none; position: relative; padding-left: 50%; } - .woocommerce-licenses-table td:before { + .woocommerce-licenses-table td:before, + .licenses-table td:before { content: attr(data-title); position: absolute; left: 0.75em; diff --git a/assets/js/checkout-blocks.js b/assets/js/checkout-blocks.js index 83053f0..1cdd17b 100644 --- a/assets/js/checkout-blocks.js +++ b/assets/js/checkout-blocks.js @@ -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 = ` +
+ ${settings.fieldDescription || 'Enter a unique domain for each license.'} +
+ ${settings.licensedProducts.map(product => ` ++ ${settings.fieldDescription || 'Enter the domain where you will use the license.'} +
+- -
-+ +
+
+ + +
+- +
@@ -251,7 +283,7 @@ final class OrderLicenseController ?>
- 0 && $orderDomain && $order->is_paid()): ?> + 0 && $hasDomainData && $order->is_paid()): ?>
__('Order must be paid before licenses can be generated.', 'wc-licensed-product')]);
}
- // Get domain
- $domain = $order->get_meta('_licensed_product_domain');
- if (empty($domain)) {
+ // Check for multi-domain format first
+ $multiDomainData = $order->get_meta('_licensed_product_domains');
+ $legacyDomain = $order->get_meta('_licensed_product_domain');
+
+ if (!empty($multiDomainData) && is_array($multiDomainData)) {
+ // Multi-domain format
+ $result = $this->generateMultiDomainLicenses($order, $multiDomainData);
+ } elseif (!empty($legacyDomain)) {
+ // Legacy single domain format
+ $result = $this->generateLegacyLicenses($order, $legacyDomain);
+ } else {
wp_send_json_error(['message' => __('Please set the order domain before generating licenses.', 'wc-licensed-product')]);
+ return;
}
- // Generate licenses for each licensed product
- $generated = 0;
- $skipped = 0;
-
- foreach ($order->get_items() as $item) {
- $product = $item->get_product();
- if ($product && $product->is_type('licensed')) {
- $license = $this->licenseManager->generateLicense(
- $orderId,
- $product->get_id(),
- $order->get_customer_id(),
- $domain
- );
-
- if ($license) {
- // Check if this is a new license or existing
- $existingLicenses = $this->licenseManager->getLicensesByOrder($orderId);
- $isNew = true;
- foreach ($existingLicenses as $existing) {
- if ($existing->getProductId() === $product->get_id() && $existing->getId() !== $license->getId()) {
- $isNew = false;
- break;
- }
- }
- if ($isNew) {
- $generated++;
- } else {
- $skipped++;
- }
- }
- }
- }
-
- if ($generated > 0) {
+ if ($result['generated'] > 0) {
wp_send_json_success([
'message' => sprintf(
/* translators: %d: Number of licenses generated */
_n(
'%d license generated successfully.',
'%d licenses generated successfully.',
- $generated,
+ $result['generated'],
'wc-licensed-product'
),
- $generated
+ $result['generated']
),
- 'generated' => $generated,
- 'skipped' => $skipped,
+ 'generated' => $result['generated'],
+ 'skipped' => $result['skipped'],
'reload' => true,
]);
} else {
wp_send_json_success([
'message' => __('All licenses already exist for this order.', 'wc-licensed-product'),
'generated' => 0,
- 'skipped' => $skipped,
+ 'skipped' => $result['skipped'],
'reload' => false,
]);
}
}
+
+ /**
+ * Generate licenses for multi-domain format
+ */
+ private function generateMultiDomainLicenses(\WC_Order $order, array $domainData): array
+ {
+ $generated = 0;
+ $skipped = 0;
+ $orderId = $order->get_id();
+ $customerId = $order->get_customer_id();
+
+ // Index domains by product ID
+ $domainsByProduct = [];
+ foreach ($domainData as $item) {
+ if (isset($item['product_id']) && isset($item['domains']) && is_array($item['domains'])) {
+ $domainsByProduct[(int) $item['product_id']] = $item['domains'];
+ }
+ }
+
+ foreach ($order->get_items() as $item) {
+ $product = $item->get_product();
+ if (!$product || !$product->is_type('licensed')) {
+ continue;
+ }
+
+ $productId = $product->get_id();
+ $domains = $domainsByProduct[$productId] ?? [];
+
+ // Get existing licenses for this product
+ $existingLicenses = $this->licenseManager->getLicensesByOrderAndProduct($orderId, $productId);
+ $existingDomains = array_map(fn($l) => $l->getDomain(), $existingLicenses);
+
+ foreach ($domains as $domain) {
+ $normalizedDomain = $this->licenseManager->normalizeDomain($domain);
+
+ // Skip if license already exists for this domain
+ if (in_array($normalizedDomain, $existingDomains, true)) {
+ $skipped++;
+ continue;
+ }
+
+ $license = $this->licenseManager->generateLicense(
+ $orderId,
+ $productId,
+ $customerId,
+ $normalizedDomain
+ );
+
+ if ($license) {
+ $generated++;
+ }
+ }
+ }
+
+ return ['generated' => $generated, 'skipped' => $skipped];
+ }
+
+ /**
+ * Generate licenses for legacy single domain format
+ */
+ private function generateLegacyLicenses(\WC_Order $order, string $domain): array
+ {
+ $generated = 0;
+ $skipped = 0;
+ $orderId = $order->get_id();
+ $customerId = $order->get_customer_id();
+
+ foreach ($order->get_items() as $item) {
+ $product = $item->get_product();
+ if (!$product || !$product->is_type('licensed')) {
+ continue;
+ }
+
+ // Check if license already exists
+ $existing = $this->licenseManager->getLicenseByOrderAndProduct($orderId, $product->get_id());
+ if ($existing) {
+ $skipped++;
+ continue;
+ }
+
+ $license = $this->licenseManager->generateLicense(
+ $orderId,
+ $product->get_id(),
+ $customerId,
+ $domain
+ );
+
+ if ($license) {
+ $generated++;
+ }
+ }
+
+ return ['generated' => $generated, 'skipped' => $skipped];
+ }
}
diff --git a/src/Admin/SettingsController.php b/src/Admin/SettingsController.php
index 93b1a1f..24e8679 100644
--- a/src/Admin/SettingsController.php
+++ b/src/Admin/SettingsController.php
@@ -202,6 +202,13 @@ final class SettingsController
'id' => 'wc_licensed_product_default_bind_to_version',
'default' => 'no',
],
+ 'enable_multi_domain' => [
+ 'name' => __('Enable Multi-Domain Licensing', 'wc-licensed-product'),
+ 'type' => 'checkbox',
+ 'desc' => __('Allow customers to purchase multiple licenses for different domains at once. Each unit in cart quantity requires a unique domain.', 'wc-licensed-product'),
+ 'id' => 'wc_licensed_product_enable_multi_domain',
+ 'default' => 'no',
+ ],
'section_end' => [
'type' => 'sectionend',
'id' => 'wc_licensed_product_section_defaults_end',
@@ -387,6 +394,14 @@ final class SettingsController
return get_option('wc_licensed_product_default_bind_to_version', 'no') === 'yes';
}
+ /**
+ * Check if multi-domain licensing is enabled
+ */
+ public static function isMultiDomainEnabled(): bool
+ {
+ return get_option('wc_licensed_product_enable_multi_domain', 'no') === 'yes';
+ }
+
/**
* Check if expiration warning emails are enabled
* This checks both the WooCommerce email setting and the old setting for backwards compatibility
diff --git a/src/Checkout/CheckoutBlocksIntegration.php b/src/Checkout/CheckoutBlocksIntegration.php
index cb759f0..0386265 100644
--- a/src/Checkout/CheckoutBlocksIntegration.php
+++ b/src/Checkout/CheckoutBlocksIntegration.php
@@ -10,6 +10,7 @@ declare(strict_types=1);
namespace Jeremias\WcLicensedProduct\Checkout;
use Automattic\WooCommerce\Blocks\Integrations\IntegrationInterface;
+use Jeremias\WcLicensedProduct\Admin\SettingsController;
/**
* Integration with WooCommerce Checkout Blocks
@@ -30,7 +31,7 @@ final class CheckoutBlocksIntegration implements IntegrationInterface
public function initialize(): void
{
$this->registerScripts();
- $this->registerBlockExtensionData();
+ $this->registerAdditionalCheckoutFields();
}
/**
@@ -45,7 +46,7 @@ final class CheckoutBlocksIntegration implements IntegrationInterface
wp_register_script(
'wc-licensed-product-checkout-blocks',
$scriptUrl,
- ['wc-blocks-checkout', 'wp-element', 'wp-components', 'wp-i18n'],
+ ['wc-blocks-checkout', 'wp-element', 'wp-components', 'wp-i18n', 'wp-plugins', 'wp-data'],
WC_LICENSED_PRODUCT_VERSION,
true
);
@@ -59,20 +60,33 @@ final class CheckoutBlocksIntegration implements IntegrationInterface
}
/**
- * Register block extension data
+ * Register additional checkout fields using WooCommerce Blocks API
*/
- private function registerBlockExtensionData(): void
+ private function registerAdditionalCheckoutFields(): void
{
- // Pass data to the checkout block script
- add_filter(
- 'woocommerce_blocks_checkout_block_registration_data',
- function (array $data): array {
- $data['wc-licensed-product'] = [
- 'hasLicensedProducts' => $this->cartHasLicensedProducts(),
- ];
- return $data;
+ add_action('woocommerce_blocks_loaded', function (): void {
+ // Check if the function exists (WooCommerce 8.9+)
+ if (!function_exists('woocommerce_register_additional_checkout_field')) {
+ return;
}
- );
+
+ // Register the domain field using WooCommerce's checkout fields API
+ // For single domain mode only (multi-domain uses custom JS component)
+ if (!SettingsController::isMultiDomainEnabled()) {
+ woocommerce_register_additional_checkout_field([
+ 'id' => 'wc-licensed-product/domain',
+ 'label' => __('License Domain', 'wc-licensed-product'),
+ 'location' => 'order',
+ 'type' => 'text',
+ 'required' => false,
+ 'attributes' => [
+ 'placeholder' => __('example.com', 'wc-licensed-product'),
+ 'pattern' => '^([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}$',
+ 'title' => __('Enter a valid domain (without http:// or www)', 'wc-licensed-product'),
+ ],
+ ]);
+ }
+ });
}
/**
@@ -96,13 +110,23 @@ final class CheckoutBlocksIntegration implements IntegrationInterface
*/
public function get_script_data(): array
{
+ $isMultiDomain = SettingsController::isMultiDomainEnabled();
+
return [
'hasLicensedProducts' => $this->cartHasLicensedProducts(),
- 'fieldLabel' => __('Domain for License Activation', 'wc-licensed-product'),
+ 'licensedProducts' => $this->getLicensedProductsFromCart(),
+ 'isMultiDomainEnabled' => $isMultiDomain,
'fieldPlaceholder' => __('example.com', 'wc-licensed-product'),
- 'fieldDescription' => __('Enter the domain where you will use this license (without http:// or www).', 'wc-licensed-product'),
- 'sectionTitle' => __('License Domain', 'wc-licensed-product'),
- 'validationError' => __('Please enter a valid domain for your license activation.', 'wc-licensed-product'),
+ 'fieldDescription' => $isMultiDomain
+ ? __('Enter a unique domain for each license (without http:// or www).', 'wc-licensed-product')
+ : __('Enter the domain where you will use the license (without http:// or www).', 'wc-licensed-product'),
+ 'sectionTitle' => $isMultiDomain
+ ? __('License Domains', 'wc-licensed-product')
+ : __('License Domain', 'wc-licensed-product'),
+ 'validationError' => __('Please enter a valid domain.', 'wc-licensed-product'),
+ 'duplicateError' => __('Each license requires a unique domain.', 'wc-licensed-product'),
+ 'licenseLabel' => __('License %d:', 'wc-licensed-product'),
+ 'singleDomainLabel' => __('Domain', 'wc-licensed-product'),
];
}
@@ -110,18 +134,34 @@ final class CheckoutBlocksIntegration implements IntegrationInterface
* Check if cart contains licensed products
*/
private function cartHasLicensedProducts(): bool
+ {
+ return !empty($this->getLicensedProductsFromCart());
+ }
+
+ /**
+ * Get licensed products from cart with quantities
+ *
+ * @return array
-
+
+
+
+
+
+
+ :
+ :
-
-
-
@@ -302,29 +325,33 @@ final class LicenseEmailController
/**
* Render license info in plain text format
*/
- private function renderPlainTextLicenseInfo(array $licenses, \WC_Order $order): void
+ private function renderPlainTextLicenseInfo(array $products, \WC_Order $order): void
{
- $domain = $order->get_meta('_licensed_product_domain');
-
echo "\n\n";
echo "==========================================================\n";
echo esc_html__('YOUR LICENSE KEYS', 'wc-licensed-product') . "\n";
echo "==========================================================\n\n";
- if ($domain) {
- echo esc_html__('Licensed Domain:', 'wc-licensed-product') . ' ' . esc_html($domain) . "\n\n";
- }
+ foreach ($products as $product) {
+ echo esc_html($product['product_name']);
+ echo ' (' . count($product['licenses']) . ' ' .
+ _n('license', 'licenses', count($product['licenses']), 'wc-licensed-product') . ')';
+ echo "\n";
+ echo "-----------------------------------------------------------\n";
- foreach ($licenses as $item) {
- echo esc_html($item['product_name']) . "\n";
- echo esc_html__('License Key:', 'wc-licensed-product') . ' ' . esc_html($item['license']->getLicenseKey()) . "\n";
+ foreach ($product['licenses'] as $license) {
+ echo esc_html__('License Key:', 'wc-licensed-product') . ' ';
+ echo esc_html($license->getLicenseKey()) . "\n";
+ echo esc_html__('Domain:', 'wc-licensed-product') . ' ';
+ echo esc_html($license->getDomain()) . "\n";
+ echo esc_html__('Expires:', 'wc-licensed-product') . ' ';
- $expiresAt = $item['license']->getExpiresAt();
- echo esc_html__('Expires:', 'wc-licensed-product') . ' ';
- echo $expiresAt
- ? esc_html($expiresAt->format(get_option('date_format')))
- : esc_html__('Never', 'wc-licensed-product');
- echo "\n\n";
+ $expiresAt = $license->getExpiresAt();
+ echo $expiresAt
+ ? esc_html($expiresAt->format(get_option('date_format')))
+ : esc_html__('Never', 'wc-licensed-product');
+ echo "\n\n";
+ }
}
echo esc_html__('You can also view your licenses in your account under "Licenses".', 'wc-licensed-product') . "\n";
diff --git a/src/Frontend/AccountController.php b/src/Frontend/AccountController.php
index 78c0b96..6b26039 100644
--- a/src/Frontend/AccountController.php
+++ b/src/Frontend/AccountController.php
@@ -107,135 +107,248 @@ final class AccountController
$licenses = $this->licenseManager->getLicensesByCustomer($customerId);
- // Enrich licenses with product data and downloads
- $enrichedLicenses = [];
- foreach ($licenses as $license) {
- $product = wc_get_product($license->getProductId());
- $order = wc_get_order($license->getOrderId());
-
- // Get available downloads for this license
- $downloads = [];
- if ($license->getStatus() === 'active') {
- $versions = $this->versionManager->getVersionsByProduct($license->getProductId());
- foreach ($versions as $version) {
- if ($version->isActive() && ($version->getAttachmentId() || $version->getDownloadUrl())) {
- $downloads[] = [
- 'version' => $version->getVersion(),
- 'version_id' => $version->getId(),
- 'filename' => $version->getDownloadFilename(),
- 'download_url' => $this->downloadController->generateDownloadUrl(
- $license->getId(),
- $version->getId()
- ),
- 'release_notes' => $version->getReleaseNotes(),
- 'released_at' => $version->getReleasedAt()->format(get_option('date_format')),
- 'file_hash' => $version->getFileHash(),
- ];
- }
- }
- }
-
- $enrichedLicenses[] = [
- 'license' => $license,
- 'product_name' => $product ? $product->get_name() : __('Unknown Product', 'wc-licensed-product'),
- 'product_url' => $product ? $product->get_permalink() : '',
- 'order_number' => $order ? $order->get_order_number() : '',
- 'order_url' => $order ? $order->get_view_order_url() : '',
- 'downloads' => $downloads,
- ];
- }
+ // Group licenses by product+order into "packages"
+ $packages = $this->groupLicensesIntoPackages($licenses);
try {
echo $this->twig->render('frontend/licenses.html.twig', [
- 'licenses' => $enrichedLicenses,
- 'has_licenses' => !empty($enrichedLicenses),
+ 'packages' => $packages,
+ 'has_packages' => !empty($packages),
]);
} catch (\Exception $e) {
// Fallback to PHP template if Twig fails
- $this->displayLicensesFallback($enrichedLicenses);
+ $this->displayLicensesFallback($packages);
}
}
+ /**
+ * Group licenses into packages by product+order
+ *
+ * @param array $licenses Array of License objects
+ * @return array Array of package data
+ */
+ private function groupLicensesIntoPackages(array $licenses): array
+ {
+ $grouped = [];
+
+ foreach ($licenses as $license) {
+ $productId = $license->getProductId();
+ $orderId = $license->getOrderId();
+ $key = $productId . '_' . $orderId;
+
+ if (!isset($grouped[$key])) {
+ $product = wc_get_product($productId);
+ $order = wc_get_order($orderId);
+
+ $grouped[$key] = [
+ 'product_id' => $productId,
+ 'order_id' => $orderId,
+ 'product_name' => $product ? $product->get_name() : __('Unknown Product', 'wc-licensed-product'),
+ 'product_url' => $product ? $product->get_permalink() : '',
+ 'order_number' => $order ? $order->get_order_number() : '',
+ 'order_url' => $order ? $order->get_view_order_url() : '',
+ 'licenses' => [],
+ 'downloads' => [],
+ 'has_active_license' => false,
+ ];
+ }
+
+ // Add license to package
+ $grouped[$key]['licenses'][] = [
+ 'id' => $license->getId(),
+ 'license_key' => $license->getLicenseKey(),
+ 'domain' => $license->getDomain(),
+ 'status' => $license->getStatus(),
+ 'expires_at' => $license->getExpiresAt(),
+ 'is_transferable' => in_array($license->getStatus(), ['active', 'inactive'], true),
+ ];
+
+ // Track if package has at least one active license
+ if ($license->getStatus() === 'active') {
+ $grouped[$key]['has_active_license'] = true;
+ }
+ }
+
+ // Add downloads for packages with active licenses
+ foreach ($grouped as $key => &$package) {
+ if ($package['has_active_license']) {
+ $package['downloads'] = $this->getDownloadsForProduct(
+ $package['product_id'],
+ $package['licenses'][0]['id'] // Use first license for download URL
+ );
+ }
+ }
+
+ // Sort by order date (newest first) - re-index array
+ return array_values($grouped);
+ }
+
+ /**
+ * Get downloads for a product
+ */
+ private function getDownloadsForProduct(int $productId, int $licenseId): array
+ {
+ $downloads = [];
+ $versions = $this->versionManager->getVersionsByProduct($productId);
+
+ foreach ($versions as $version) {
+ if ($version->isActive() && ($version->getAttachmentId() || $version->getDownloadUrl())) {
+ $downloads[] = [
+ 'version' => $version->getVersion(),
+ 'version_id' => $version->getId(),
+ 'filename' => $version->getDownloadFilename(),
+ 'download_url' => $this->downloadController->generateDownloadUrl(
+ $licenseId,
+ $version->getId()
+ ),
+ 'release_notes' => $version->getReleaseNotes(),
+ 'released_at' => $version->getReleasedAt()->format(get_option('date_format')),
+ 'file_hash' => $version->getFileHash(),
+ ];
+ }
+ }
+
+ return $downloads;
+ }
+
/**
* Fallback display method if Twig is unavailable
*/
- private function displayLicensesFallback(array $enrichedLicenses): void
+ private function displayLicensesFallback(array $packages): void
{
- if (empty($enrichedLicenses)) {
+ if (empty($packages)) {
echo ' ' . esc_html__('You have no licenses yet.', 'wc-licensed-product') . ' {{ __('You have no licenses yet.') }}
+ 1) {
+ printf(' (×%d)', $productData['quantity']);
+ }
+ ?>
+
+
+
+ getSavedDomainValue($productId, $i);
+ ?>
+
+
+
+
+
- getLicenseKey()); ?>
-
+
+
+
+ getLicenseKey()); ?>
+
+
+ getDomain()); ?>
+
+
+
+
+ ()
+
+
-
-
-
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- getLicenseKey()); ?>
-
-
- getExpiresAt();
- echo $expiresAt
- ? esc_html($expiresAt->format(get_option('date_format')))
- : esc_html__('Never', 'wc-licensed-product');
- ?>
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ getLicenseKey()); ?>
+
+
+ getDomain()); ?>
+
+
+ getExpiresAt();
+ echo $expiresAt
+ ? esc_html($expiresAt->format(get_option('date_format')))
+ : esc_html__('Never', 'wc-licensed-product');
+ ?>
+
+
- getLicenseKey()); ?>
-
-
-
+
+
+
+
- {% if item.product_url %}
- {{ esc_html(item.product_name) }}
- {% else %}
- {{ esc_html(item.product_name) }}
- {% endif %}
-
-
- {{ esc_html(item.license.status)|capitalize }}
+ {% for package in packages %}
+
+ {% if package.product_url %}
+ {{ esc_html(package.product_name) }}
+ {% else %}
+ {{ esc_html(package.product_name) }}
+ {% endif %}
+
+
+ {{ __('Order') }}
+ {% if package.order_url %}
+ #{{ esc_html(package.order_number) }}
+ {% else %}
+ #{{ esc_html(package.order_number) }}
+ {% endif %}
+
+
- {{ esc_html(item.license.licenseKey) }}
-
-
- {{ esc_html(license.license_key) }}
+
+ {{ esc_html(license.status)|capitalize }}
+
+ {{ __('Available Downloads') }}
- {% for download in item.downloads %}
-
+
+ {# Show older versions in collapsible if more than one version exists #}
+ {% if package.downloads|length > 1 %}
+