9 Commits

Author SHA1 Message Date
c45816b491 Add release package v0.5.0
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-25 18:35:26 +01:00
bcabf8feb2 Bump version to 0.5.0
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-25 18:32:24 +01:00
83836d69af 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>
2026-01-25 18:31:36 +01:00
550a84beb9 Update CLAUDE.md with v0.4.0 session history
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-24 17:01:50 +01:00
7d48028f62 Add release package v0.4.0
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-24 16:45:33 +01:00
2ec3f42b1f Bump version to 0.4.0
- Add CHANGELOG entry for self-licensing prevention feature
- Update plugin header and constant to 0.4.0

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-24 16:42:39 +01:00
4817175f99 Add self-licensing prevention to PluginLicenseChecker
- Add isSelfLicensing() method to detect when license server URL points to same installation
- Bypass license validation when self-licensing detected (prevents circular dependency)
- Add normalizeDomain() helper for domain comparison
- Update translations

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-24 16:41:56 +01:00
a4561057fa Update CLAUDE.md with v0.3.9 session history
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-24 16:11:09 +01:00
d15c59b7c3 Add release package v0.3.9
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-24 16:10:00 +01:00
26 changed files with 3064 additions and 1189 deletions

View File

@@ -7,6 +7,53 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased] ## [Unreleased]
## [0.5.0] - 2026-01-25
### Added
- Multi-domain licensing support: Customers can now purchase multiple licenses for different domains in a single order
- Each cart item quantity requires a unique domain at checkout
- New "Enable Multi-Domain Licensing" setting in WooCommerce > Settings > Licensed Products
- Multi-domain checkout UI for WooCommerce Blocks checkout
- DOM injection fallback for checkout domain fields when React component fails to render
- Grouped license display in customer account page by product/order
- "Older versions" collapsible section in customer download area
- Updated email templates to show licenses grouped by product
### Changed
- Customer account licenses page now shows licenses grouped by product package
- Order meta now stores `_licensed_product_domains` array for multi-domain orders
- Updated translations with 19 new strings for multi-domain functionality (de_CH)
- Refactored checkout blocks JavaScript to use ExperimentalOrderMeta slot pattern
### Technical Details
- `CheckoutBlocksIntegration` now uses `registerPlugin` with `woocommerce-checkout` scope
- `StoreApiExtension` handles both single-domain and multi-domain data formats
- `CheckoutController` validates unique domains per product in multi-domain mode
- `AccountController` groups licenses by product for package-style display
- Backward compatible: existing single-domain orders continue to work
## [0.4.0] - 2026-01-24
### Added
- Self-licensing prevention: Plugin automatically bypasses license validation when the configured license server URL points to the same WordPress installation
- New `isSelfLicensing()` method in `PluginLicenseChecker` to detect circular licensing scenarios
- New `normalizeDomain()` helper method for domain comparison (strips www prefix, lowercases)
### Changed
- `isLicenseValid()` and `validateLicense()` now check for self-licensing before attempting validation
- Cache clearing now also clears the self-licensing check cache
### Technical Details
- Self-licensing detection compares normalized domains of license server URL and current site URL
- Prevents circular dependency where plugin would try to validate against itself
- Plugins can only be validated against the original store from which they were obtained
## [0.3.9] - 2026-01-24 ## [0.3.9] - 2026-01-24
### Added ### Added

View File

@@ -36,11 +36,7 @@ This project is proudly **"vibe-coded"** using Claude.AI - the entire codebase w
No known bugs at the moment. No known bugs at the moment.
### Version 0.3.9 ### Version 0.5.0
No changes at the moment.
### Version 0.4.0
No changes at the moment. No changes at the moment.
@@ -1125,3 +1121,72 @@ Fixed a critical translation bug that caused the settings page to crash with an
- Created release package: `releases/wc-licensed-product-0.3.8.zip` (829 KB) - Created release package: `releases/wc-licensed-product-0.3.8.zip` (829 KB)
- SHA256: `50ad6966c5ab8db2257572084d2d8a820448df62615678e1576696f2c0cb383d` - SHA256: `50ad6966c5ab8db2257572084d2d8a820448df62615678e1576696f2c0cb383d`
- Tagged as `v0.3.8` and pushed to `main` branch - Tagged as `v0.3.8` and pushed to `main` branch
### 2026-01-24 - Version 0.3.9 - Admin Order License Generation Fix
**Overview:**
Fixed a critical bug where licenses were not generated for orders created manually in the WordPress admin area.
**Bug Fix:**
- **Critical:** Licenses are now generated for orders created manually in admin area
- Previously, licenses were only generated via checkout hooks (`woocommerce_order_status_completed`, `woocommerce_order_status_processing`, `woocommerce_payment_complete`)
- Admin-created orders bypassed checkout, so the `_licensed_product_domain` meta was never set and licenses were never generated
**Implemented:**
- "Generate Licenses" button in order meta box for admin-created orders
- "Generate Missing Licenses" button when some products in an order already have licenses
- Warning message when order domain is not set before generating licenses
- AJAX handler `ajaxGenerateOrderLicenses()` for manual license generation
**Modified files:**
- `src/Admin/OrderLicenseController.php` - Added Generate button, AJAX handler, CSS styles
- `assets/js/order-licenses.js` - Added `generateLicenses()` function with page reload on success
**Technical notes:**
- Button only appears when order is paid and domain is set
- Uses existing `LicenseManager::generateLicense()` which handles duplicate prevention
- Page reloads after successful generation to show new licenses in table
- Tracks generated vs skipped licenses for accurate feedback messages
- Updated translations (365 strings)
**Release v0.3.9:**
- Created release package: `releases/wc-licensed-product-0.3.9.zip` (851 KB)
- SHA256: `fdb65200c368da380df0cabb3c6ac6419d5b4731cd528f630f9b432a3ba5c586`
- Tagged as `v0.3.9` and pushed to `main` branch
### 2026-01-24 - Version 0.4.0 - Self-Licensing Prevention
**Overview:**
Added self-licensing prevention to avoid circular dependency when the plugin tries to validate its license against itself.
**Implemented:**
- Self-licensing detection: Plugin automatically bypasses license validation when the configured license server URL points to the same WordPress installation
- New `isSelfLicensing()` method in `PluginLicenseChecker` to detect circular licensing scenarios
- New `normalizeDomain()` helper method for domain comparison (strips www prefix, lowercases)
- Cache property `$isSelfLicensingCached` for efficient repeated checks
**Modified files:**
- `src/License/PluginLicenseChecker.php` - Added self-licensing detection methods and bypass logic
**Technical notes:**
- Self-licensing detection compares normalized domains of license server URL and current site URL
- Prevents circular dependency where plugin would try to validate against itself
- Plugins can only be validated against the original store from which they were obtained
- Bypass check added to both `isLicenseValid()` and `validateLicense()` methods
- Cache clearing via `clearCache()` also clears the self-licensing check cache
**Release v0.4.0:**
- Created release package: `releases/wc-licensed-product-0.4.0.zip` (852 KB)
- SHA256: `cf8769c861d77c327f178049d5fac0d4e47679cc1a1d35c5b613e4cd3fb8674f`
- Tagged as `v0.4.0` and pushed to `main` branch

View File

@@ -37,13 +37,196 @@
color: #383d41; color: #383d41;
} }
/* License Cards */ /* License Packages */
.woocommerce-licenses { .woocommerce-licenses {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 1.5em; 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 { .license-card {
border: 1px solid #e5e5e5; border: 1px solid #e5e5e5;
border-radius: 8px; border-radius: 8px;
@@ -184,12 +367,14 @@
} }
/* Download Section */ /* Download Section */
.package-downloads,
.license-downloads { .license-downloads {
padding: 1em 1.5em; padding: 1em 1.5em;
background: #f8f9fa; background: #f8f9fa;
border-top: 1px solid #e5e5e5; border-top: 1px solid #e5e5e5;
} }
.package-downloads h4,
.license-downloads h4 { .license-downloads h4 {
margin: 0 0 0.75em 0; margin: 0 0 0.75em 0;
font-size: 0.95em; font-size: 0.95em;
@@ -282,6 +467,71 @@
color: #666; 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 */ /* Domain Field */
#licensed-product-domain-field { #licensed-product-domain-field {
margin-top: 2em; margin-top: 2em;
@@ -333,6 +583,52 @@
/* Responsive */ /* Responsive */
@media screen and (max-width: 768px) { @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 { .license-header {
flex-direction: column; flex-direction: column;
align-items: flex-start; align-items: flex-start;
@@ -354,33 +650,44 @@
flex-wrap: wrap; flex-wrap: wrap;
} }
/* Legacy table responsive */
.woocommerce-licenses-table, .woocommerce-licenses-table,
.woocommerce-licenses-table thead, .woocommerce-licenses-table thead,
.woocommerce-licenses-table tbody, .woocommerce-licenses-table tbody,
.woocommerce-licenses-table th, .woocommerce-licenses-table th,
.woocommerce-licenses-table td, .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; display: block;
} }
.woocommerce-licenses-table thead tr { .woocommerce-licenses-table thead tr,
.licenses-table thead tr {
position: absolute; position: absolute;
top: -9999px; top: -9999px;
left: -9999px; left: -9999px;
} }
.woocommerce-licenses-table tr { .woocommerce-licenses-table tr,
.licenses-table tr {
border: 1px solid #e5e5e5; border: 1px solid #e5e5e5;
margin-bottom: 1em; margin-bottom: 1em;
} }
.woocommerce-licenses-table td { .woocommerce-licenses-table td,
.licenses-table td {
border: none; border: none;
position: relative; position: relative;
padding-left: 50%; padding-left: 50%;
} }
.woocommerce-licenses-table td:before { .woocommerce-licenses-table td:before,
.licenses-table td:before {
content: attr(data-title); content: attr(data-title);
position: absolute; position: absolute;
left: 0.75em; left: 0.75em;

View File

@@ -1,7 +1,8 @@
/** /**
* WooCommerce Checkout Blocks Integration * 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 * @package WcLicensedProduct
*/ */
@@ -9,92 +10,333 @@
(function () { (function () {
'use strict'; 'use strict';
const { registerCheckoutBlock } = wc.blocksCheckout; // Check dependencies
const { createElement, useState, useEffect } = wp.element; 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 { TextControl } = wp.components;
const { __ } = wp.i18n; 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', {}); const settings = getSetting('wc-licensed-product_data', {});
// Check if we have licensed products
if (!settings.hasLicensedProducts) {
return;
}
/** /**
* Validate domain format * Validate domain format
*/ */
function isValidDomain(domain) { function isValidDomain(domain) {
if (!domain || domain.length > 255) { if (!domain || domain.length > 255) return false;
return false;
}
const pattern = /^([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}$/; const pattern = /^([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}$/;
return pattern.test(domain); return pattern.test(domain);
} }
/** /**
* Normalize domain (remove protocol and www) * Normalize domain
*/ */
function normalizeDomain(domain) { function normalizeDomain(domain) {
let normalized = domain.toLowerCase().trim(); return domain.toLowerCase().trim()
normalized = normalized.replace(/^https?:\/\//, ''); .replace(/^https?:\/\//, '')
normalized = normalized.replace(/^www\./, ''); .replace(/^www\./, '')
normalized = normalized.replace(/\/.*$/, ''); .replace(/\/.*$/, '');
return normalized;
} }
/** /**
* License Domain Block Component * Single Domain Component
*/ */
const LicenseDomainBlock = ({ checkoutExtensionData, extensions }) => { const SingleDomainField = () => {
const [domain, setDomain] = useState(''); const [domain, setDomain] = useState('');
const [error, setError] = useState(''); const [error, setError] = useState('');
const { setExtensionData } = checkoutExtensionData;
// Only show if cart has licensed products
if (!settings.hasLicensedProducts) {
return null;
}
const handleChange = (value) => { const handleChange = (value) => {
const normalized = normalizeDomain(value); const normalized = normalizeDomain(value);
setDomain(normalized); setDomain(normalized);
// Validate
if (normalized && !isValidDomain(normalized)) { if (normalized && !isValidDomain(normalized)) {
setError(settings.validationError || __('Please enter a valid domain.', 'wc-licensed-product')); setError(settings.validationError || __('Please enter a valid domain.', 'wc-licensed-product'));
} else { } else {
setError(''); setError('');
} }
// Update extension data for server-side processing // Store in hidden input for form submission
setExtensionData('wc-licensed-product', 'licensed_product_domain', normalized); const hiddenInput = document.getElementById('wclp-domain-hidden');
if (hiddenInput) {
hiddenInput.value = normalized;
}
}; };
return createElement( return createElement(
'div', 'div',
{ className: 'wc-block-components-licensed-product-domain' }, {
createElement( className: 'wc-block-components-licensed-product-domain',
'h3', style: {
{ className: 'wc-block-components-title' }, padding: '16px',
backgroundColor: '#f0f0f0',
borderRadius: '4px',
marginBottom: '16px',
}
},
createElement('h4', { style: { marginTop: 0, marginBottom: '8px' } },
settings.sectionTitle || __('License Domain', 'wc-licensed-product') 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, { createElement(TextControl, {
label: settings.fieldLabel || __('Domain for License Activation', 'wc-licensed-product'), label: settings.singleDomainLabel || __('Domain', 'wc-licensed-product'),
value: domain, value: domain,
onChange: handleChange, onChange: handleChange,
placeholder: settings.fieldPlaceholder || 'example.com', 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' : '', 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({ * Multi-Domain Component
metadata: { */
name: 'wc-licensed-product/domain-field', const MultiDomainFields = () => {
parent: ['woocommerce/checkout-contact-information-block'], const products = settings.licensedProducts || [];
}, const [domains, setDomains] = useState(() => {
component: LicenseDomainBlock, 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);
})(); })();

View File

@@ -25,6 +25,9 @@
$(document).on('click', '.wclp-modal-close, .wclp-modal-cancel, .wclp-modal-overlay', this.closeTransferModal.bind(this)); $(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)); $(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 // Close modal on escape key
$(document).on('keyup', function(e) { $(document).on('keyup', function(e) {
if (e.key === 'Escape') { 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 * Copy license key to clipboard
*/ */

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

Binary file not shown.

View File

@@ -0,0 +1 @@
fdb65200c368da380df0cabb3c6ac6419d5b4731cd528f630f9b432a3ba5c586 releases/wc-licensed-product-0.3.9.zip

Binary file not shown.

View File

@@ -0,0 +1 @@
cf8769c861d77c327f178049d5fac0d4e47679cc1a1d35c5b613e4cd3fb8674f wc-licensed-product-0.4.0.zip

Binary file not shown.

View File

@@ -0,0 +1 @@
446804948e5f99d705b548061d5b78180856984c58458640a910ada8f27f5316 wc-licensed-product-0.5.0.zip

View File

@@ -94,8 +94,10 @@ final class OrderLicenseController
return; return;
} }
// Get order domain // Check for multi-domain format first, then fall back to legacy single domain
$orderDomain = $order->get_meta('_licensed_product_domain'); $multiDomainData = $order->get_meta('_licensed_product_domains');
$legacyDomain = $order->get_meta('_licensed_product_domain');
$hasMultiDomain = !empty($multiDomainData) && is_array($multiDomainData);
// Get licenses for this order // Get licenses for this order
$licenses = $this->licenseManager->getLicensesByOrder($order->get_id()); $licenses = $this->licenseManager->getLicensesByOrder($order->get_id());
@@ -104,23 +106,42 @@ final class OrderLicenseController
?> ?>
<div class="wclp-order-licenses"> <div class="wclp-order-licenses">
<div class="wclp-order-domain-section"> <div class="wclp-order-domain-section">
<h4><?php esc_html_e('Order Domain', 'wc-licensed-product'); ?></h4> <h4><?php esc_html_e('Order Domains', 'wc-licensed-product'); ?></h4>
<p class="description">
<?php esc_html_e('The domain specified during checkout. Changing this will not automatically update existing license domains.', 'wc-licensed-product'); ?> <?php if ($hasMultiDomain): ?>
</p> <p class="description">
<div class="wclp-inline-edit"> <?php esc_html_e('Domains specified during checkout (multi-domain order).', 'wc-licensed-product'); ?>
<input type="text" </p>
id="wclp-order-domain" <div class="wclp-multi-domain-display" style="margin-top: 10px;">
class="regular-text" <?php foreach ($multiDomainData as $item): ?>
value="<?php echo esc_attr($orderDomain); ?>" <?php
data-order-id="<?php echo esc_attr($order->get_id()); ?>" $product = wc_get_product($item['product_id']);
placeholder="<?php esc_attr_e('example.com', 'wc-licensed-product'); ?>" /> $productName = $product ? $product->get_name() : __('Unknown Product', 'wc-licensed-product');
<button type="button" class="button" id="wclp-save-order-domain"> ?>
<?php esc_html_e('Save', 'wc-licensed-product'); ?> <div class="wclp-product-domains-item" style="margin-bottom: 10px; padding: 10px; background: #f8f8f8; border-radius: 4px;">
</button> <strong><?php echo esc_html($productName); ?>:</strong><br>
<span class="spinner"></span> <code><?php echo esc_html(implode(', ', $item['domains'])); ?></code>
<span class="wclp-status-message"></span> </div>
</div> <?php endforeach; ?>
</div>
<?php else: ?>
<p class="description">
<?php esc_html_e('The domain specified during checkout. Changing this will not automatically update existing license domains.', 'wc-licensed-product'); ?>
</p>
<div class="wclp-inline-edit">
<input type="text"
id="wclp-order-domain"
class="regular-text"
value="<?php echo esc_attr($legacyDomain); ?>"
data-order-id="<?php echo esc_attr($order->get_id()); ?>"
placeholder="<?php esc_attr_e('example.com', 'wc-licensed-product'); ?>" />
<button type="button" class="button" id="wclp-save-order-domain">
<?php esc_html_e('Save', 'wc-licensed-product'); ?>
</button>
<span class="spinner"></span>
<span class="wclp-status-message"></span>
</div>
<?php endif; ?>
</div> </div>
<hr /> <hr />
@@ -128,15 +149,26 @@ final class OrderLicenseController
<h4><?php esc_html_e('Licenses', 'wc-licensed-product'); ?></h4> <h4><?php esc_html_e('Licenses', 'wc-licensed-product'); ?></h4>
<?php <?php
// Count licensed products to check if all have licenses // Count expected licenses based on domain data
$licensedProductCount = 0; $expectedLicenses = 0;
foreach ($order->get_items() as $item) { if ($hasMultiDomain) {
$product = $item->get_product(); // Multi-domain: count total domains across all products
if ($product && $product->is_type('licensed')) { foreach ($multiDomainData as $item) {
$licensedProductCount++; if (isset($item['domains']) && is_array($item['domains'])) {
$expectedLicenses += count($item['domains']);
}
}
} else {
// Legacy: one license per licensed product
foreach ($order->get_items() as $item) {
$product = $item->get_product();
if ($product && $product->is_type('licensed')) {
$expectedLicenses++;
}
} }
} }
$missingLicenses = $licensedProductCount - count($licenses); $missingLicenses = $expectedLicenses - count($licenses);
$hasDomainData = $hasMultiDomain || !empty($legacyDomain);
?> ?>
<?php if (empty($licenses)): ?> <?php if (empty($licenses)): ?>
@@ -150,7 +182,7 @@ final class OrderLicenseController
<em><?php esc_html_e('Licenses will be generated when the order is marked as paid/completed.', 'wc-licensed-product'); ?></em> <em><?php esc_html_e('Licenses will be generated when the order is marked as paid/completed.', 'wc-licensed-product'); ?></em>
<?php endif; ?> <?php endif; ?>
</p> </p>
<?php if ($orderDomain && $order->is_paid()): ?> <?php if ($hasDomainData && $order->is_paid()): ?>
<p style="margin-top: 10px;"> <p style="margin-top: 10px;">
<button type="button" class="button button-primary" id="wclp-generate-licenses" data-order-id="<?php echo esc_attr($order->get_id()); ?>"> <button type="button" class="button button-primary" id="wclp-generate-licenses" data-order-id="<?php echo esc_attr($order->get_id()); ?>">
<?php esc_html_e('Generate Licenses', 'wc-licensed-product'); ?> <?php esc_html_e('Generate Licenses', 'wc-licensed-product'); ?>
@@ -158,7 +190,7 @@ final class OrderLicenseController
<span class="spinner" style="float: none; margin-top: 4px;"></span> <span class="spinner" style="float: none; margin-top: 4px;"></span>
<span class="wclp-generate-status"></span> <span class="wclp-generate-status"></span>
</p> </p>
<?php elseif (!$orderDomain): ?> <?php elseif (!$hasDomainData): ?>
<p class="description" style="margin-top: 10px; color: #d63638;"> <p class="description" style="margin-top: 10px; color: #d63638;">
<span class="dashicons dashicons-warning"></span> <span class="dashicons dashicons-warning"></span>
<?php esc_html_e('Please set the order domain above before generating licenses.', 'wc-licensed-product'); ?> <?php esc_html_e('Please set the order domain above before generating licenses.', 'wc-licensed-product'); ?>
@@ -251,7 +283,7 @@ final class OrderLicenseController
?> ?>
</p> </p>
<?php if ($missingLicenses > 0 && $orderDomain && $order->is_paid()): ?> <?php if ($missingLicenses > 0 && $hasDomainData && $order->is_paid()): ?>
<p style="margin-top: 10px;"> <p style="margin-top: 10px;">
<span class="dashicons dashicons-warning" style="color: #dba617;"></span> <span class="dashicons dashicons-warning" style="color: #dba617;"></span>
<?php <?php
@@ -474,68 +506,138 @@ final class OrderLicenseController
wp_send_json_error(['message' => __('Order must be paid before licenses can be generated.', 'wc-licensed-product')]); wp_send_json_error(['message' => __('Order must be paid before licenses can be generated.', 'wc-licensed-product')]);
} }
// Get domain // Check for multi-domain format first
$domain = $order->get_meta('_licensed_product_domain'); $multiDomainData = $order->get_meta('_licensed_product_domains');
if (empty($domain)) { $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')]); wp_send_json_error(['message' => __('Please set the order domain before generating licenses.', 'wc-licensed-product')]);
return;
} }
// Generate licenses for each licensed product if ($result['generated'] > 0) {
$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) {
wp_send_json_success([ wp_send_json_success([
'message' => sprintf( 'message' => sprintf(
/* translators: %d: Number of licenses generated */ /* translators: %d: Number of licenses generated */
_n( _n(
'%d license generated successfully.', '%d license generated successfully.',
'%d licenses generated successfully.', '%d licenses generated successfully.',
$generated, $result['generated'],
'wc-licensed-product' 'wc-licensed-product'
), ),
$generated $result['generated']
), ),
'generated' => $generated, 'generated' => $result['generated'],
'skipped' => $skipped, 'skipped' => $result['skipped'],
'reload' => true, 'reload' => true,
]); ]);
} else { } else {
wp_send_json_success([ wp_send_json_success([
'message' => __('All licenses already exist for this order.', 'wc-licensed-product'), 'message' => __('All licenses already exist for this order.', 'wc-licensed-product'),
'generated' => 0, 'generated' => 0,
'skipped' => $skipped, 'skipped' => $result['skipped'],
'reload' => false, '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];
}
} }

View File

@@ -202,6 +202,13 @@ final class SettingsController
'id' => 'wc_licensed_product_default_bind_to_version', 'id' => 'wc_licensed_product_default_bind_to_version',
'default' => 'no', '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' => [ 'section_end' => [
'type' => 'sectionend', 'type' => 'sectionend',
'id' => 'wc_licensed_product_section_defaults_end', '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'; 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 * Check if expiration warning emails are enabled
* This checks both the WooCommerce email setting and the old setting for backwards compatibility * This checks both the WooCommerce email setting and the old setting for backwards compatibility

View File

@@ -10,6 +10,7 @@ declare(strict_types=1);
namespace Jeremias\WcLicensedProduct\Checkout; namespace Jeremias\WcLicensedProduct\Checkout;
use Automattic\WooCommerce\Blocks\Integrations\IntegrationInterface; use Automattic\WooCommerce\Blocks\Integrations\IntegrationInterface;
use Jeremias\WcLicensedProduct\Admin\SettingsController;
/** /**
* Integration with WooCommerce Checkout Blocks * Integration with WooCommerce Checkout Blocks
@@ -30,7 +31,7 @@ final class CheckoutBlocksIntegration implements IntegrationInterface
public function initialize(): void public function initialize(): void
{ {
$this->registerScripts(); $this->registerScripts();
$this->registerBlockExtensionData(); $this->registerAdditionalCheckoutFields();
} }
/** /**
@@ -45,7 +46,7 @@ final class CheckoutBlocksIntegration implements IntegrationInterface
wp_register_script( wp_register_script(
'wc-licensed-product-checkout-blocks', 'wc-licensed-product-checkout-blocks',
$scriptUrl, $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, WC_LICENSED_PRODUCT_VERSION,
true 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_action('woocommerce_blocks_loaded', function (): void {
add_filter( // Check if the function exists (WooCommerce 8.9+)
'woocommerce_blocks_checkout_block_registration_data', if (!function_exists('woocommerce_register_additional_checkout_field')) {
function (array $data): array { return;
$data['wc-licensed-product'] = [
'hasLicensedProducts' => $this->cartHasLicensedProducts(),
];
return $data;
} }
);
// 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 public function get_script_data(): array
{ {
$isMultiDomain = SettingsController::isMultiDomainEnabled();
return [ return [
'hasLicensedProducts' => $this->cartHasLicensedProducts(), 'hasLicensedProducts' => $this->cartHasLicensedProducts(),
'fieldLabel' => __('Domain for License Activation', 'wc-licensed-product'), 'licensedProducts' => $this->getLicensedProductsFromCart(),
'isMultiDomainEnabled' => $isMultiDomain,
'fieldPlaceholder' => __('example.com', 'wc-licensed-product'), 'fieldPlaceholder' => __('example.com', 'wc-licensed-product'),
'fieldDescription' => __('Enter the domain where you will use this license (without http:// or www).', 'wc-licensed-product'), 'fieldDescription' => $isMultiDomain
'sectionTitle' => __('License Domain', 'wc-licensed-product'), ? __('Enter a unique domain for each license (without http:// or www).', 'wc-licensed-product')
'validationError' => __('Please enter a valid domain for your license activation.', '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 * Check if cart contains licensed products
*/ */
private function cartHasLicensedProducts(): bool private function cartHasLicensedProducts(): bool
{
return !empty($this->getLicensedProductsFromCart());
}
/**
* Get licensed products from cart with quantities
*
* @return array<int, array{product_id: int, name: string, quantity: int}>
*/
private function getLicensedProductsFromCart(): array
{ {
if (!WC()->cart) { if (!WC()->cart) {
return false; return [];
} }
$licensedProducts = [];
foreach (WC()->cart->get_cart() as $cartItem) { foreach (WC()->cart->get_cart() as $cartItem) {
$product = $cartItem['data']; $product = $cartItem['data'];
if ($product && $product->is_type('licensed')) { if ($product && $product->is_type('licensed')) {
return true; $productId = $product->get_id();
$licensedProducts[] = [
'product_id' => $productId,
'name' => $product->get_name(),
'quantity' => (int) $cartItem['quantity'],
];
} }
} }
return false; return $licensedProducts;
} }
} }

View File

@@ -10,6 +10,7 @@ declare(strict_types=1);
namespace Jeremias\WcLicensedProduct\Checkout; namespace Jeremias\WcLicensedProduct\Checkout;
use Jeremias\WcLicensedProduct\License\LicenseManager; use Jeremias\WcLicensedProduct\License\LicenseManager;
use Jeremias\WcLicensedProduct\Admin\SettingsController;
/** /**
* Handles checkout modifications for licensed products * Handles checkout modifications for licensed products
@@ -50,35 +51,75 @@ final class CheckoutController
*/ */
private function cartHasLicensedProducts(): bool private function cartHasLicensedProducts(): bool
{ {
if (!WC()->cart) { return !empty($this->getLicensedProductsFromCart());
return false;
}
foreach (WC()->cart->get_cart() as $cartItem) {
$product = $cartItem['data'];
if ($product && $product->is_type('licensed')) {
return true;
}
}
return false;
} }
/** /**
* Add domain field to checkout form * Get licensed products from cart with quantities
*
* @return array<int, array{product_id: int, name: string, quantity: int}>
*/
private function getLicensedProductsFromCart(): array
{
if (!WC()->cart) {
return [];
}
$licensedProducts = [];
foreach (WC()->cart->get_cart() as $cartItem) {
$product = $cartItem['data'];
if ($product && $product->is_type('licensed')) {
$productId = $product->get_id();
$licensedProducts[$productId] = [
'product_id' => $productId,
'name' => $product->get_name(),
'quantity' => (int) $cartItem['quantity'],
];
}
}
return $licensedProducts;
}
/**
* Add domain fields to checkout form
* Shows multiple domain fields if multi-domain is enabled, otherwise single field
*/ */
public function addDomainField(): void public function addDomainField(): void
{ {
if (!$this->cartHasLicensedProducts()) { $licensedProducts = $this->getLicensedProductsFromCart();
if (empty($licensedProducts)) {
return; return;
} }
// Check if multi-domain licensing is enabled
if (SettingsController::isMultiDomainEnabled()) {
$this->renderMultiDomainFields($licensedProducts);
} else {
$this->renderSingleDomainField();
}
}
/**
* Render single domain field (legacy mode)
*/
private function renderSingleDomainField(): void
{
$savedValue = '';
// Check POST data first (validation failure case)
if (isset($_POST['licensed_product_domain'])) {
$savedValue = sanitize_text_field($_POST['licensed_product_domain']);
} elseif (WC()->session) {
$savedValue = WC()->session->get('licensed_product_domain', '');
}
?> ?>
<div id="licensed-product-domain-field"> <div id="licensed-product-domain-field">
<h3><?php esc_html_e('License Domain', 'wc-licensed-product'); ?></h3> <h3><?php esc_html_e('License Domain', 'wc-licensed-product'); ?></h3>
<p class="form-row form-row-wide"> <p class="form-row form-row-wide">
<label for="licensed_product_domain"> <label for="licensed_product_domain">
<?php esc_html_e('Domain for License Activation', 'wc-licensed-product'); ?> <?php esc_html_e('Domain', 'wc-licensed-product'); ?>
<abbr class="required" title="<?php esc_attr_e('required', 'wc-licensed-product'); ?>">*</abbr> <abbr class="required" title="<?php esc_attr_e('required', 'wc-licensed-product'); ?>">*</abbr>
</label> </label>
<input <input
@@ -87,10 +128,10 @@ final class CheckoutController
name="licensed_product_domain" name="licensed_product_domain"
id="licensed_product_domain" id="licensed_product_domain"
placeholder="<?php esc_attr_e('example.com', 'wc-licensed-product'); ?>" placeholder="<?php esc_attr_e('example.com', 'wc-licensed-product'); ?>"
value="<?php echo esc_attr(WC()->checkout->get_value('licensed_product_domain')); ?>" value="<?php echo esc_attr($savedValue); ?>"
/> />
<span class="description"> <span class="description">
<?php esc_html_e('Enter the domain where you will use this license (without http:// or www).', 'wc-licensed-product'); ?> <?php esc_html_e('Enter the domain where you will use the license (without http:// or www).', 'wc-licensed-product'); ?>
</span> </span>
</p> </p>
</div> </div>
@@ -98,62 +139,276 @@ final class CheckoutController
} }
/** /**
* Validate domain field during checkout * Render multi-domain fields (one per quantity)
*/
private function renderMultiDomainFields(array $licensedProducts): void
{
?>
<div id="licensed-product-domain-fields">
<h3><?php esc_html_e('License Domains', 'wc-licensed-product'); ?></h3>
<p class="wclp-domain-description">
<?php esc_html_e('Enter a unique domain for each license (without http:// or www).', 'wc-licensed-product'); ?>
</p>
<?php foreach ($licensedProducts as $productId => $productData): ?>
<div class="wclp-product-domains" data-product-id="<?php echo esc_attr($productId); ?>">
<h4>
<?php
echo esc_html($productData['name']);
if ($productData['quantity'] > 1) {
printf(' (×%d)', $productData['quantity']);
}
?>
</h4>
<?php for ($i = 0; $i < $productData['quantity']; $i++): ?>
<?php
$fieldName = sprintf('licensed_domains[%d][%d]', $productId, $i);
$fieldId = sprintf('licensed_domain_%d_%d', $productId, $i);
$savedValue = $this->getSavedDomainValue($productId, $i);
?>
<p class="form-row form-row-wide wclp-domain-row">
<label for="<?php echo esc_attr($fieldId); ?>">
<?php
printf(
/* translators: %d: license number */
esc_html__('License %d:', 'wc-licensed-product'),
$i + 1
);
?>
<abbr class="required" title="<?php esc_attr_e('required', 'wc-licensed-product'); ?>">*</abbr>
</label>
<input
type="text"
class="input-text wclp-domain-input"
name="<?php echo esc_attr($fieldName); ?>"
id="<?php echo esc_attr($fieldId); ?>"
placeholder="<?php esc_attr_e('example.com', 'wc-licensed-product'); ?>"
value="<?php echo esc_attr($savedValue); ?>"
/>
</p>
<?php endfor; ?>
</div>
<?php endforeach; ?>
</div>
<style>
#licensed-product-domain-fields { margin-bottom: 20px; }
#licensed-product-domain-fields h3 { margin-bottom: 10px; }
.wclp-domain-description { margin-bottom: 15px; color: #666; }
.wclp-product-domains { margin-bottom: 20px; padding: 15px; background: #f8f8f8; border-radius: 4px; }
.wclp-product-domains h4 { margin: 0 0 10px 0; font-size: 1em; }
.wclp-domain-row { margin-bottom: 10px; }
.wclp-domain-row:last-child { margin-bottom: 0; }
.wclp-domain-row label { display: block; margin-bottom: 5px; }
</style>
<?php
}
/**
* Get saved domain value from session/POST
*/
private function getSavedDomainValue(int $productId, int $index): string
{
// Check POST data first (validation failure case)
if (isset($_POST['licensed_domains'][$productId][$index])) {
return sanitize_text_field($_POST['licensed_domains'][$productId][$index]);
}
// Check session for blocks checkout
if (WC()->session) {
$sessionDomains = WC()->session->get('licensed_product_domains', []);
foreach ($sessionDomains as $item) {
if (isset($item['product_id']) && (int) $item['product_id'] === $productId) {
if (isset($item['domains'][$index])) {
return $item['domains'][$index];
}
}
}
}
return '';
}
/**
* Validate domain fields during checkout
*/ */
public function validateDomainField(): void public function validateDomainField(): void
{ {
if (!$this->cartHasLicensedProducts()) { $licensedProducts = $this->getLicensedProductsFromCart();
if (empty($licensedProducts)) {
return; return;
} }
$domain = isset($_POST['licensed_product_domain']) // Check if multi-domain licensing is enabled
? sanitize_text_field($_POST['licensed_product_domain']) if (SettingsController::isMultiDomainEnabled()) {
: ''; $this->validateMultiDomainFields($licensedProducts);
} else {
if (empty($domain)) { $this->validateSingleDomainField();
wc_add_notice(
__('Please enter a domain for your license activation.', 'wc-licensed-product'),
'error'
);
return;
}
// Validate domain format
$normalizedDomain = $this->licenseManager->normalizeDomain($domain);
if (!$this->isValidDomain($normalizedDomain)) {
wc_add_notice(
__('Please enter a valid domain name.', 'wc-licensed-product'),
'error'
);
} }
} }
/** /**
* Save domain field to order meta * Validate single domain field
*/ */
public function saveDomainField(int $orderId): void private function validateSingleDomainField(): void
{ {
if (!$this->cartHasLicensedProducts()) { $domain = isset($_POST['licensed_product_domain']) ? sanitize_text_field($_POST['licensed_product_domain']) : '';
if (empty($domain)) {
wc_add_notice(__('Please enter a domain for your license.', 'wc-licensed-product'), 'error');
return; return;
} }
if (isset($_POST['licensed_product_domain']) && !empty($_POST['licensed_product_domain'])) { $normalizedDomain = $this->licenseManager->normalizeDomain($domain);
$domain = sanitize_text_field($_POST['licensed_product_domain']); if (!$this->isValidDomain($normalizedDomain)) {
$normalizedDomain = $this->licenseManager->normalizeDomain($domain); wc_add_notice(__('Please enter a valid domain for your license.', 'wc-licensed-product'), 'error');
}
}
$order = wc_get_order($orderId); /**
if ($order) { * Validate multi-domain fields
$order->update_meta_data('_licensed_product_domain', $normalizedDomain); */
$order->save(); private function validateMultiDomainFields(array $licensedProducts): void
{
$licensedDomains = $_POST['licensed_domains'] ?? [];
foreach ($licensedProducts as $productId => $productData) {
$productDomains = $licensedDomains[$productId] ?? [];
$normalizedDomains = [];
for ($i = 0; $i < $productData['quantity']; $i++) {
$domain = isset($productDomains[$i]) ? sanitize_text_field($productDomains[$i]) : '';
// Check if domain is empty
if (empty($domain)) {
wc_add_notice(
sprintf(
/* translators: 1: product name, 2: license number */
__('Please enter a domain for %1$s (License %2$d).', 'wc-licensed-product'),
$productData['name'],
$i + 1
),
'error'
);
continue;
}
// Validate domain format
$normalizedDomain = $this->licenseManager->normalizeDomain($domain);
if (!$this->isValidDomain($normalizedDomain)) {
wc_add_notice(
sprintf(
/* translators: 1: product name, 2: license number */
__('Please enter a valid domain for %1$s (License %2$d).', 'wc-licensed-product'),
$productData['name'],
$i + 1
),
'error'
);
continue;
}
// Check for duplicate domains within same product
if (in_array($normalizedDomain, $normalizedDomains, true)) {
wc_add_notice(
sprintf(
/* translators: 1: domain name, 2: product name */
__('The domain "%1$s" is used multiple times for %2$s. Each license requires a unique domain.', 'wc-licensed-product'),
$normalizedDomain,
$productData['name']
),
'error'
);
} else {
$normalizedDomains[] = $normalizedDomain;
}
} }
} }
} }
/** /**
* Display domain in admin order view * Save domain fields to order meta
*/
public function saveDomainField(int $orderId): void
{
$licensedProducts = $this->getLicensedProductsFromCart();
if (empty($licensedProducts)) {
return;
}
$order = wc_get_order($orderId);
if (!$order) {
return;
}
// Check if multi-domain licensing is enabled
if (SettingsController::isMultiDomainEnabled()) {
$this->saveMultiDomainFields($order, $licensedProducts);
} else {
$this->saveSingleDomainField($order);
}
}
/**
* Save single domain field to order meta (legacy format)
*/
private function saveSingleDomainField(\WC_Order $order): void
{
$domain = isset($_POST['licensed_product_domain']) ? sanitize_text_field($_POST['licensed_product_domain']) : '';
if (!empty($domain)) {
$normalizedDomain = $this->licenseManager->normalizeDomain($domain);
$order->update_meta_data('_licensed_product_domain', $normalizedDomain);
$order->save();
}
}
/**
* Save multi-domain fields to order meta
*/
private function saveMultiDomainFields(\WC_Order $order, array $licensedProducts): void
{
$licensedDomains = $_POST['licensed_domains'] ?? [];
$domainData = [];
foreach ($licensedProducts as $productId => $productData) {
$productDomains = $licensedDomains[$productId] ?? [];
$normalizedDomains = [];
for ($i = 0; $i < $productData['quantity']; $i++) {
$domain = isset($productDomains[$i]) ? sanitize_text_field($productDomains[$i]) : '';
if (!empty($domain)) {
$normalizedDomains[] = $this->licenseManager->normalizeDomain($domain);
}
}
if (!empty($normalizedDomains)) {
$domainData[] = [
'product_id' => $productId,
'domains' => $normalizedDomains,
];
}
}
if (!empty($domainData)) {
$order->update_meta_data('_licensed_product_domains', $domainData);
$order->save();
}
}
/**
* Display domains in admin order view
*/ */
public function displayDomainInAdmin(\WC_Order $order): void public function displayDomainInAdmin(\WC_Order $order): void
{ {
// Try new multi-domain format first
$domainData = $order->get_meta('_licensed_product_domains');
if (!empty($domainData) && is_array($domainData)) {
$this->displayMultiDomainsInAdmin($domainData);
return;
}
// Fall back to legacy single domain
$domain = $order->get_meta('_licensed_product_domain'); $domain = $order->get_meta('_licensed_product_domain');
if (!$domain) { if (!$domain) {
return; return;
@@ -168,10 +423,40 @@ final class CheckoutController
} }
/** /**
* Display domain in order emails * Display multi-domain data in admin
*/
private function displayMultiDomainsInAdmin(array $domainData): void
{
?>
<div class="wclp-order-domains">
<strong><?php esc_html_e('License Domains:', 'wc-licensed-product'); ?></strong>
<?php foreach ($domainData as $item): ?>
<?php
$product = wc_get_product($item['product_id']);
$productName = $product ? $product->get_name() : __('Unknown Product', 'wc-licensed-product');
?>
<p style="margin: 5px 0 5px 15px;">
<em><?php echo esc_html($productName); ?>:</em><br>
<?php echo esc_html(implode(', ', $item['domains'])); ?>
</p>
<?php endforeach; ?>
</div>
<?php
}
/**
* Display domains in order emails
*/ */
public function displayDomainInEmail(\WC_Order $order, bool $sentToAdmin, bool $plainText): void public function displayDomainInEmail(\WC_Order $order, bool $sentToAdmin, bool $plainText): void
{ {
// Try new multi-domain format first
$domainData = $order->get_meta('_licensed_product_domains');
if (!empty($domainData) && is_array($domainData)) {
$this->displayMultiDomainsInEmail($domainData, $plainText);
return;
}
// Fall back to legacy single domain
$domain = $order->get_meta('_licensed_product_domain'); $domain = $order->get_meta('_licensed_product_domain');
if (!$domain) { if (!$domain) {
return; return;
@@ -189,6 +474,37 @@ final class CheckoutController
} }
} }
/**
* Display multi-domain data in email
*/
private function displayMultiDomainsInEmail(array $domainData, bool $plainText): void
{
if ($plainText) {
echo "\n" . esc_html__('License Domains:', 'wc-licensed-product') . "\n";
foreach ($domainData as $item) {
$product = wc_get_product($item['product_id']);
$productName = $product ? $product->get_name() : __('Unknown Product', 'wc-licensed-product');
echo ' ' . esc_html($productName) . ': ' . esc_html(implode(', ', $item['domains'])) . "\n";
}
} else {
?>
<div style="margin-bottom: 15px;">
<strong><?php esc_html_e('License Domains:', 'wc-licensed-product'); ?></strong>
<?php foreach ($domainData as $item): ?>
<?php
$product = wc_get_product($item['product_id']);
$productName = $product ? $product->get_name() : __('Unknown Product', 'wc-licensed-product');
?>
<p style="margin: 5px 0 5px 15px;">
<em><?php echo esc_html($productName); ?>:</em><br>
<?php echo esc_html(implode(', ', $item['domains'])); ?>
</p>
<?php endforeach; ?>
</div>
<?php
}
}
/** /**
* Validate domain format * Validate domain format
*/ */

View File

@@ -12,6 +12,7 @@ namespace Jeremias\WcLicensedProduct\Checkout;
use Automattic\WooCommerce\StoreApi\Schemas\V1\CheckoutSchema; use Automattic\WooCommerce\StoreApi\Schemas\V1\CheckoutSchema;
use Automattic\WooCommerce\StoreApi\StoreApi; use Automattic\WooCommerce\StoreApi\StoreApi;
use Automattic\WooCommerce\StoreApi\Schemas\ExtendSchema; use Automattic\WooCommerce\StoreApi\Schemas\ExtendSchema;
use Jeremias\WcLicensedProduct\Admin\SettingsController;
use Jeremias\WcLicensedProduct\License\LicenseManager; use Jeremias\WcLicensedProduct\License\LicenseManager;
/** /**
@@ -70,6 +71,12 @@ final class StoreApiExtension
*/ */
public function getExtensionData(): array public function getExtensionData(): array
{ {
if (SettingsController::isMultiDomainEnabled()) {
return [
'licensed_product_domains' => WC()->session ? WC()->session->get('licensed_product_domains', []) : [],
];
}
return [ return [
'licensed_product_domain' => WC()->session ? WC()->session->get('licensed_product_domain', '') : '', 'licensed_product_domain' => WC()->session ? WC()->session->get('licensed_product_domain', '') : '',
]; ];
@@ -80,6 +87,31 @@ final class StoreApiExtension
*/ */
public function getExtensionSchema(): array public function getExtensionSchema(): array
{ {
if (SettingsController::isMultiDomainEnabled()) {
return [
'licensed_product_domains' => [
'description' => __('Domains for license activation by product', 'wc-licensed-product'),
'type' => 'array',
'context' => ['view', 'edit'],
'readonly' => false,
'items' => [
'type' => 'object',
'properties' => [
'product_id' => [
'type' => 'integer',
],
'domains' => [
'type' => 'array',
'items' => [
'type' => 'string',
],
],
],
],
],
];
}
return [ return [
'licensed_product_domain' => [ 'licensed_product_domain' => [
'description' => __('Domain for license activation', 'wc-licensed-product'), 'description' => __('Domain for license activation', 'wc-licensed-product'),
@@ -95,30 +127,103 @@ final class StoreApiExtension
*/ */
public function handleExtensionUpdate(array $data): void public function handleExtensionUpdate(array $data): void
{ {
if (isset($data['licensed_product_domain'])) { if (SettingsController::isMultiDomainEnabled()) {
$domain = sanitize_text_field($data['licensed_product_domain']); // Multi-domain mode
$normalizedDomain = $this->licenseManager->normalizeDomain($domain); if (isset($data['licensed_product_domains']) && is_array($data['licensed_product_domains'])) {
$normalizedData = $this->normalizeDomainsData($data['licensed_product_domains']);
if (WC()->session) { if (WC()->session) {
WC()->session->set('licensed_product_domain', $normalizedDomain); WC()->session->set('licensed_product_domains', $normalizedData);
}
}
} else {
// Single domain mode
if (isset($data['licensed_product_domain'])) {
$sanitized = sanitize_text_field($data['licensed_product_domain']);
$normalized = $this->licenseManager->normalizeDomain($sanitized);
if (WC()->session) {
WC()->session->set('licensed_product_domain', $normalized);
}
} }
} }
} }
/** /**
* Process the checkout order - save domain to order meta * Normalize domains data from frontend
*/
private function normalizeDomainsData(array $domainsData): array
{
$normalized = [];
foreach ($domainsData as $item) {
if (!isset($item['product_id']) || !isset($item['domains']) || !is_array($item['domains'])) {
continue;
}
$productId = (int) $item['product_id'];
$domains = [];
foreach ($item['domains'] as $domain) {
$sanitized = sanitize_text_field($domain);
if (!empty($sanitized)) {
$domains[] = $this->licenseManager->normalizeDomain($sanitized);
}
}
if (!empty($domains)) {
$normalized[] = [
'product_id' => $productId,
'domains' => $domains,
];
}
}
return $normalized;
}
/**
* Process the checkout order - save domains to order meta
*/ */
public function processCheckoutOrder(\WC_Order $order): void public function processCheckoutOrder(\WC_Order $order): void
{ {
$domain = WC()->session ? WC()->session->get('licensed_product_domain', '') : ''; $requestData = json_decode(file_get_contents('php://input'), true);
// Also check in the request data for block checkout if (SettingsController::isMultiDomainEnabled()) {
if (empty($domain)) { $this->processMultiDomainOrder($order, $requestData);
$requestData = json_decode(file_get_contents('php://input'), true); } else {
if (isset($requestData['extensions'][self::IDENTIFIER]['licensed_product_domain'])) { $this->processSingleDomainOrder($order, $requestData);
$domain = sanitize_text_field($requestData['extensions'][self::IDENTIFIER]['licensed_product_domain']); }
$domain = $this->licenseManager->normalizeDomain($domain); }
}
/**
* Process order in single domain mode (legacy)
*/
private function processSingleDomainOrder(\WC_Order $order, ?array $requestData): void
{
$domain = '';
// Check session first
if (WC()->session) {
$domain = WC()->session->get('licensed_product_domain', '');
}
// Check in the request data for block checkout (extension data)
if (empty($domain) && isset($requestData['extensions'][self::IDENTIFIER]['licensed_product_domain'])) {
$sanitized = sanitize_text_field($requestData['extensions'][self::IDENTIFIER]['licensed_product_domain']);
$domain = $this->licenseManager->normalizeDomain($sanitized);
}
// Check for wclp_license_domain (from our hidden input)
if (empty($domain) && isset($requestData['wclp_license_domain'])) {
$sanitized = sanitize_text_field($requestData['wclp_license_domain']);
$domain = $this->licenseManager->normalizeDomain($sanitized);
}
// Check for additional_fields (WC Blocks API)
if (empty($domain) && isset($requestData['additional_fields']['wc-licensed-product/domain'])) {
$sanitized = sanitize_text_field($requestData['additional_fields']['wc-licensed-product/domain']);
$domain = $this->licenseManager->normalizeDomain($sanitized);
} }
if (!empty($domain)) { if (!empty($domain)) {
@@ -131,4 +236,65 @@ final class StoreApiExtension
} }
} }
} }
/**
* Process order in multi-domain mode
*/
private function processMultiDomainOrder(\WC_Order $order, ?array $requestData): void
{
$domainData = [];
// Check session first
if (WC()->session) {
$domainData = WC()->session->get('licensed_product_domains', []);
}
// Check in the request data for block checkout (extension data)
if (empty($domainData) && isset($requestData['extensions'][self::IDENTIFIER]['licensed_product_domains'])) {
$domainData = $this->normalizeDomainsData(
$requestData['extensions'][self::IDENTIFIER]['licensed_product_domains']
);
}
// Check for wclp_license_domains (from our hidden input - JSON string)
if (empty($domainData) && isset($requestData['wclp_license_domains'])) {
$parsed = json_decode($requestData['wclp_license_domains'], true);
if (is_array($parsed)) {
$domainData = $this->normalizeDomainsData($parsed);
}
}
// Check for licensed_domains in classic format (from DOM injection)
if (empty($domainData) && isset($requestData['licensed_domains']) && is_array($requestData['licensed_domains'])) {
$domainData = [];
foreach ($requestData['licensed_domains'] as $productId => $domains) {
if (!is_array($domains)) {
continue;
}
$normalizedDomains = [];
foreach ($domains as $domain) {
$sanitized = sanitize_text_field($domain);
if (!empty($sanitized)) {
$normalizedDomains[] = $this->licenseManager->normalizeDomain($sanitized);
}
}
if (!empty($normalizedDomains)) {
$domainData[] = [
'product_id' => (int) $productId,
'domains' => $normalizedDomains,
];
}
}
}
if (!empty($domainData)) {
$order->update_meta_data('_licensed_product_domains', $domainData);
$order->save();
// Clear session data
if (WC()->session) {
WC()->session->set('licensed_product_domains', []);
}
}
}
} }

View File

@@ -194,7 +194,7 @@ final class LicenseEmailController
} }
/** /**
* Add license key to order item in email * Add license key(s) to order item in email
*/ */
public function addLicenseToOrderItem(int $itemId, \WC_Order_Item $item, \WC_Order $order, bool $plainText): void public function addLicenseToOrderItem(int $itemId, \WC_Order_Item $item, \WC_Order $order, bool $plainText): void
{ {
@@ -203,94 +203,117 @@ final class LicenseEmailController
return; return;
} }
$license = $this->licenseManager->getLicenseByOrderAndProduct($order->get_id(), $product->get_id()); $licenses = $this->licenseManager->getLicensesByOrderAndProduct($order->get_id(), $product->get_id());
if (!$license) { if (empty($licenses)) {
return; return;
} }
if ($plainText) { if ($plainText) {
echo "\n" . esc_html__('License Key:', 'wc-licensed-product') . ' ' . esc_html($license->getLicenseKey()) . "\n"; echo "\n" . esc_html__('License Keys:', 'wc-licensed-product') . "\n";
foreach ($licenses as $license) {
echo ' - ' . esc_html($license->getLicenseKey());
echo ' (' . esc_html($license->getDomain()) . ')' . "\n";
}
} else { } else {
?> ?>
<div style="margin-top: 10px; padding: 10px; background-color: #f8f9fa; border-left: 3px solid #7f54b3;"> <div style="margin-top: 10px; padding: 10px; background-color: #f8f9fa; border-left: 3px solid #7f54b3;">
<strong><?php esc_html_e('License Key:', 'wc-licensed-product'); ?></strong> <strong><?php esc_html_e('License Keys:', 'wc-licensed-product'); ?></strong>
<code style="display: block; margin-top: 5px; padding: 5px; background: #fff; font-family: monospace;"> <?php foreach ($licenses as $license) : ?>
<?php echo esc_html($license->getLicenseKey()); ?> <div style="margin-top: 5px; padding: 5px; background: #fff;">
</code> <code style="font-family: monospace;">
<?php echo esc_html($license->getLicenseKey()); ?>
</code>
<span style="color: #666; margin-left: 10px;">
<?php echo esc_html($license->getDomain()); ?>
</span>
</div>
<?php endforeach; ?>
</div> </div>
<?php <?php
} }
} }
/** /**
* Get all licenses for an order * Get all licenses for an order grouped by product
*
* @return array Array of products with their licenses
*/ */
private function getLicensesForOrder(\WC_Order $order): array private function getLicensesForOrder(\WC_Order $order): array
{ {
$licenses = []; $products = [];
foreach ($order->get_items() as $item) { foreach ($order->get_items() as $item) {
$product = $item->get_product(); $product = $item->get_product();
if ($product && $product->is_type('licensed')) { if ($product && $product->is_type('licensed')) {
$license = $this->licenseManager->getLicenseByOrderAndProduct($order->get_id(), $product->get_id()); $licenses = $this->licenseManager->getLicensesByOrderAndProduct($order->get_id(), $product->get_id());
if ($license) { if (!empty($licenses)) {
$licenses[] = [ $products[] = [
'license' => $license,
'product_name' => $product->get_name(), 'product_name' => $product->get_name(),
'licenses' => $licenses,
]; ];
} }
} }
} }
return $licenses; return $products;
} }
/** /**
* Render license info in HTML format * Render license info in HTML format
*/ */
private function renderHtmlLicenseInfo(array $licenses, \WC_Order $order): void private function renderHtmlLicenseInfo(array $products, \WC_Order $order): void
{ {
$domain = $order->get_meta('_licensed_product_domain');
?> ?>
<div style="margin: 20px 0; padding: 20px; background-color: #f8f9fa; border: 1px solid #e5e5e5; border-radius: 4px;"> <div style="margin: 20px 0; padding: 20px; background-color: #f8f9fa; border: 1px solid #e5e5e5; border-radius: 4px;">
<h2 style="margin-top: 0; color: #333;"><?php esc_html_e('Your License Keys', 'wc-licensed-product'); ?></h2> <h2 style="margin-top: 0; color: #333;"><?php esc_html_e('Your License Keys', 'wc-licensed-product'); ?></h2>
<?php if ($domain) : ?> <?php foreach ($products as $product) : ?>
<p style="margin-bottom: 15px;"> <div style="margin-bottom: 20px;">
<strong><?php esc_html_e('Licensed Domain:', 'wc-licensed-product'); ?></strong> <h3 style="margin: 0 0 10px 0; font-size: 1.1em; color: #333;">
<?php echo esc_html($domain); ?> <?php echo esc_html($product['product_name']); ?>
</p> <span style="font-weight: normal; color: #666; font-size: 0.9em;">
<?php endif; ?> (<?php
printf(
esc_html(_n('%d license', '%d licenses', count($product['licenses']), 'wc-licensed-product')),
count($product['licenses'])
);
?>)
</span>
</h3>
<table style="width: 100%; border-collapse: collapse;"> <table style="width: 100%; border-collapse: collapse; background: #fff;">
<thead> <thead>
<tr> <tr>
<th style="text-align: left; padding: 10px; border-bottom: 2px solid #ddd;"><?php esc_html_e('Product', 'wc-licensed-product'); ?></th> <th style="text-align: left; padding: 8px 10px; border-bottom: 2px solid #ddd; font-size: 0.9em;"><?php esc_html_e('License Key', 'wc-licensed-product'); ?></th>
<th style="text-align: left; padding: 10px; border-bottom: 2px solid #ddd;"><?php esc_html_e('License Key', 'wc-licensed-product'); ?></th> <th style="text-align: left; padding: 8px 10px; border-bottom: 2px solid #ddd; font-size: 0.9em;"><?php esc_html_e('Domain', 'wc-licensed-product'); ?></th>
<th style="text-align: left; padding: 10px; border-bottom: 2px solid #ddd;"><?php esc_html_e('Expires', 'wc-licensed-product'); ?></th> <th style="text-align: left; padding: 8px 10px; border-bottom: 2px solid #ddd; font-size: 0.9em;"><?php esc_html_e('Expires', 'wc-licensed-product'); ?></th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<?php foreach ($licenses as $item) : ?> <?php foreach ($product['licenses'] as $license) : ?>
<tr> <tr>
<td style="padding: 10px; border-bottom: 1px solid #eee;"><?php echo esc_html($item['product_name']); ?></td> <td style="padding: 8px 10px; border-bottom: 1px solid #eee;">
<td style="padding: 10px; border-bottom: 1px solid #eee;"> <code style="background: #f5f5f5; padding: 3px 6px; font-family: monospace; font-size: 0.9em;">
<code style="background: #fff; padding: 3px 6px; font-family: monospace;"> <?php echo esc_html($license->getLicenseKey()); ?>
<?php echo esc_html($item['license']->getLicenseKey()); ?> </code>
</code> </td>
</td> <td style="padding: 8px 10px; border-bottom: 1px solid #eee;">
<td style="padding: 10px; border-bottom: 1px solid #eee;"> <?php echo esc_html($license->getDomain()); ?>
<?php </td>
$expiresAt = $item['license']->getExpiresAt(); <td style="padding: 8px 10px; border-bottom: 1px solid #eee;">
echo $expiresAt <?php
? esc_html($expiresAt->format(get_option('date_format'))) $expiresAt = $license->getExpiresAt();
: esc_html__('Never', 'wc-licensed-product'); echo $expiresAt
?> ? esc_html($expiresAt->format(get_option('date_format')))
</td> : esc_html__('Never', 'wc-licensed-product');
</tr> ?>
<?php endforeach; ?> </td>
</tbody> </tr>
</table> <?php endforeach; ?>
</tbody>
</table>
</div>
<?php endforeach; ?>
<p style="margin-top: 15px; margin-bottom: 0; font-size: 0.9em; color: #666;"> <p style="margin-top: 15px; margin-bottom: 0; font-size: 0.9em; color: #666;">
<?php esc_html_e('You can also view your licenses in your account under "Licenses".', 'wc-licensed-product'); ?> <?php esc_html_e('You can also view your licenses in your account under "Licenses".', 'wc-licensed-product'); ?>
@@ -302,29 +325,33 @@ final class LicenseEmailController
/** /**
* Render license info in plain text format * 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\n";
echo "==========================================================\n"; echo "==========================================================\n";
echo esc_html__('YOUR LICENSE KEYS', 'wc-licensed-product') . "\n"; echo esc_html__('YOUR LICENSE KEYS', 'wc-licensed-product') . "\n";
echo "==========================================================\n\n"; echo "==========================================================\n\n";
if ($domain) { foreach ($products as $product) {
echo esc_html__('Licensed Domain:', 'wc-licensed-product') . ' ' . esc_html($domain) . "\n\n"; 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) { foreach ($product['licenses'] as $license) {
echo esc_html($item['product_name']) . "\n"; echo esc_html__('License Key:', 'wc-licensed-product') . ' ';
echo esc_html__('License Key:', 'wc-licensed-product') . ' ' . esc_html($item['license']->getLicenseKey()) . "\n"; 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(); $expiresAt = $license->getExpiresAt();
echo esc_html__('Expires:', 'wc-licensed-product') . ' '; echo $expiresAt
echo $expiresAt ? esc_html($expiresAt->format(get_option('date_format')))
? esc_html($expiresAt->format(get_option('date_format'))) : esc_html__('Never', 'wc-licensed-product');
: esc_html__('Never', 'wc-licensed-product'); echo "\n\n";
echo "\n\n"; }
} }
echo esc_html__('You can also view your licenses in your account under "Licenses".', 'wc-licensed-product') . "\n"; echo esc_html__('You can also view your licenses in your account under "Licenses".', 'wc-licensed-product') . "\n";

View File

@@ -107,135 +107,248 @@ final class AccountController
$licenses = $this->licenseManager->getLicensesByCustomer($customerId); $licenses = $this->licenseManager->getLicensesByCustomer($customerId);
// Enrich licenses with product data and downloads // Group licenses by product+order into "packages"
$enrichedLicenses = []; $packages = $this->groupLicensesIntoPackages($licenses);
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,
];
}
try { try {
echo $this->twig->render('frontend/licenses.html.twig', [ echo $this->twig->render('frontend/licenses.html.twig', [
'licenses' => $enrichedLicenses, 'packages' => $packages,
'has_licenses' => !empty($enrichedLicenses), 'has_packages' => !empty($packages),
]); ]);
} catch (\Exception $e) { } catch (\Exception $e) {
// Fallback to PHP template if Twig fails // 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 * 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 '<p>' . esc_html__('You have no licenses yet.', 'wc-licensed-product') . '</p>'; echo '<p>' . esc_html__('You have no licenses yet.', 'wc-licensed-product') . '</p>';
return; return;
} }
?> ?>
<div class="woocommerce-licenses"> <div class="woocommerce-licenses">
<?php foreach ($enrichedLicenses as $item): ?> <?php foreach ($packages as $package): ?>
<div class="license-card"> <div class="license-package">
<div class="license-header"> <div class="package-header">
<h3> <h3>
<?php if ($item['product_url']): ?> <?php if ($package['product_url']): ?>
<a href="<?php echo esc_url($item['product_url']); ?>"> <a href="<?php echo esc_url($package['product_url']); ?>">
<?php echo esc_html($item['product_name']); ?> <?php echo esc_html($package['product_name']); ?>
</a> </a>
<?php else: ?> <?php else: ?>
<?php echo esc_html($item['product_name']); ?> <?php echo esc_html($package['product_name']); ?>
<?php endif; ?> <?php endif; ?>
</h3> </h3>
<span class="license-status license-status-<?php echo esc_attr($item['license']->getStatus()); ?>"> <span class="package-order">
<?php echo esc_html(ucfirst($item['license']->getStatus())); ?> <?php
printf(
/* translators: %s: order number */
esc_html__('Order #%s', 'wc-licensed-product'),
esc_html($package['order_number'])
);
?>
</span> </span>
</div> </div>
<div class="license-details"> <div class="package-licenses">
<div class="license-key-row"> <?php foreach ($package['licenses'] as $license): ?>
<label><?php esc_html_e('License Key:', 'wc-licensed-product'); ?></label> <div class="license-entry license-entry-<?php echo esc_attr($license['status']); ?>">
<code class="license-key" data-license-key="<?php echo esc_attr($item['license']->getLicenseKey()); ?>"> <div class="license-row-primary">
<?php echo esc_html($item['license']->getLicenseKey()); ?> <div class="license-key-group">
</code> <code class="license-key"><?php echo esc_html($license['license_key']); ?></code>
<button type="button" class="copy-license-btn" data-license-key="<?php echo esc_attr($item['license']->getLicenseKey()); ?>" title="<?php esc_attr_e('Copy to clipboard', 'wc-licensed-product'); ?>"> <span class="license-status license-status-<?php echo esc_attr($license['status']); ?>">
<span class="dashicons dashicons-clipboard"></span> <?php echo esc_html(ucfirst($license['status'])); ?>
</button> </span>
</div> </div>
<div class="license-actions">
<div class="license-info-row"> <button type="button" class="copy-license-btn" data-license-key="<?php echo esc_attr($license['license_key']); ?>" title="<?php esc_attr_e('Copy to clipboard', 'wc-licensed-product'); ?>">
<span class="license-domain-display" data-license-id="<?php echo esc_attr($item['license']->getId()); ?>"> <span class="dashicons dashicons-clipboard"></span>
<strong><?php esc_html_e('Domain:', 'wc-licensed-product'); ?></strong> </button>
<span class="domain-value"><?php echo esc_html($item['license']->getDomain()); ?></span> <?php if ($license['is_transferable']): ?>
<?php if (in_array($item['license']->getStatus(), ['active', 'inactive'], true)): ?> <button type="button" class="wclp-transfer-btn"
<button type="button" class="wclp-transfer-btn" data-license-id="<?php echo esc_attr($license['id']); ?>"
data-license-id="<?php echo esc_attr($item['license']->getId()); ?>" data-current-domain="<?php echo esc_attr($license['domain']); ?>"
data-current-domain="<?php echo esc_attr($item['license']->getDomain()); ?>" title="<?php esc_attr_e('Transfer to new domain', 'wc-licensed-product'); ?>">
title="<?php esc_attr_e('Transfer to new domain', 'wc-licensed-product'); ?>"> <span class="dashicons dashicons-randomize"></span>
<span class="dashicons dashicons-randomize"></span> </button>
<?php esc_html_e('Transfer', 'wc-licensed-product'); ?> <?php endif; ?>
</button> </div>
<?php endif; ?> </div>
</span> <div class="license-row-secondary">
<span><strong><?php esc_html_e('Expires:', 'wc-licensed-product'); ?></strong> <span class="license-meta-item license-domain">
<?php <span class="dashicons dashicons-admin-site-alt3"></span>
$expiresAt = $item['license']->getExpiresAt(); <?php echo esc_html($license['domain']); ?>
echo $expiresAt </span>
? esc_html($expiresAt->format(get_option('date_format'))) <span class="license-meta-item license-expiry">
: esc_html__('Never', 'wc-licensed-product'); <span class="dashicons dashicons-calendar-alt"></span>
?> <?php
</span> echo $license['expires_at']
</div> ? esc_html($license['expires_at']->format('Y-m-d'))
: '<span class="lifetime">' . esc_html__('Lifetime', 'wc-licensed-product') . '</span>';
?>
</span>
</div>
</div>
<?php endforeach; ?>
</div> </div>
<?php if (!empty($item['downloads'])): ?> <?php if (!empty($package['downloads'])): ?>
<div class="license-downloads"> <div class="package-downloads">
<h4><?php esc_html_e('Available Downloads', 'wc-licensed-product'); ?></h4> <h4><?php esc_html_e('Available Downloads', 'wc-licensed-product'); ?></h4>
<ul class="download-list"> <ul class="download-list">
<?php foreach ($item['downloads'] as $download): ?> <?php
<li> $latest = $package['downloads'][0];
<a href="<?php echo esc_url($download['download_url']); ?>" class="download-link"> ?>
<li class="download-item download-item-latest">
<div class="download-row-file">
<a href="<?php echo esc_url($latest['download_url']); ?>" class="download-link">
<span class="dashicons dashicons-download"></span> <span class="dashicons dashicons-download"></span>
<?php echo esc_html($download['filename'] ?: sprintf(__('Version %s', 'wc-licensed-product'), $download['version'])); ?> <?php echo esc_html($latest['filename'] ?: sprintf(__('Version %s', 'wc-licensed-product'), $latest['version'])); ?>
</a> </a>
<span class="download-version">v<?php echo esc_html($download['version']); ?></span> <span class="download-version-badge"><?php esc_html_e('Latest', 'wc-licensed-product'); ?></span>
<span class="download-date"><?php echo esc_html($download['released_at']); ?></span> </div>
</li> <div class="download-row-meta">
<?php endforeach; ?> <span class="download-date"><?php echo esc_html($latest['released_at']); ?></span>
<?php if (!empty($latest['file_hash'])): ?>
<span class="download-hash" title="<?php echo esc_attr($latest['file_hash']); ?>">
<span class="dashicons dashicons-shield"></span>
<code><?php echo esc_html(substr($latest['file_hash'], 0, 12)); ?>...</code>
</span>
<?php endif; ?>
</div>
</li>
</ul> </ul>
<?php if (count($package['downloads']) > 1): ?>
<div class="older-versions-section">
<button type="button" class="older-versions-toggle" aria-expanded="false">
<span class="dashicons dashicons-arrow-down-alt2"></span>
<?php
printf(
esc_html__('Older versions (%d)', 'wc-licensed-product'),
count($package['downloads']) - 1
);
?>
</button>
<ul class="download-list older-versions-list" style="display: none;">
<?php foreach (array_slice($package['downloads'], 1) as $download): ?>
<li class="download-item">
<div class="download-row-file">
<a href="<?php echo esc_url($download['download_url']); ?>" class="download-link">
<span class="dashicons dashicons-download"></span>
<?php echo esc_html($download['filename'] ?: sprintf(__('Version %s', 'wc-licensed-product'), $download['version'])); ?>
</a>
</div>
<div class="download-row-meta">
<span class="download-date"><?php echo esc_html($download['released_at']); ?></span>
<?php if (!empty($download['file_hash'])): ?>
<span class="download-hash" title="<?php echo esc_attr($download['file_hash']); ?>">
<span class="dashicons dashicons-shield"></span>
<code><?php echo esc_html(substr($download['file_hash'], 0, 12)); ?>...</code>
</span>
<?php endif; ?>
</div>
</li>
<?php endforeach; ?>
</ul>
</div>
<?php endif; ?>
</div> </div>
<?php endif; ?> <?php endif; ?>
</div> </div>

View File

@@ -49,8 +49,11 @@ class LicenseManager
): ?License { ): ?License {
global $wpdb; global $wpdb;
// Check if license already exists for this order and product // Normalize domain first for duplicate detection
$existing = $this->getLicenseByOrderAndProduct($orderId, $productId); $normalizedDomain = $this->normalizeDomain($domain);
// Check if license already exists for this order, product, and domain
$existing = $this->getLicenseByOrderProductAndDomain($orderId, $productId, $normalizedDomain);
if ($existing) { if ($existing) {
return $existing; return $existing;
} }
@@ -161,6 +164,49 @@ class LicenseManager
return $row ? License::fromArray($row) : null; return $row ? License::fromArray($row) : null;
} }
/**
* Get all licenses for an order and product
*
* @return License[]
*/
public function getLicensesByOrderAndProduct(int $orderId, int $productId): array
{
global $wpdb;
$tableName = Installer::getLicensesTable();
$rows = $wpdb->get_results(
$wpdb->prepare(
"SELECT * FROM {$tableName} WHERE order_id = %d AND product_id = %d ORDER BY created_at ASC",
$orderId,
$productId
),
ARRAY_A
);
return array_map(fn(array $row) => License::fromArray($row), $rows ?: []);
}
/**
* Get license by order, product, and domain
*/
public function getLicenseByOrderProductAndDomain(int $orderId, int $productId, string $domain): ?License
{
global $wpdb;
$tableName = Installer::getLicensesTable();
$row = $wpdb->get_row(
$wpdb->prepare(
"SELECT * FROM {$tableName} WHERE order_id = %d AND product_id = %d AND domain = %s",
$orderId,
$productId,
$domain
),
ARRAY_A
);
return $row ? License::fromArray($row) : null;
}
/** /**
* Get all licenses for an order * Get all licenses for an order
*/ */

View File

@@ -52,6 +52,11 @@ final class PluginLicenseChecker
*/ */
private ?bool $isLocalhostCached = null; private ?bool $isLocalhostCached = null;
/**
* Cached self-licensing check result
*/
private ?bool $isSelfLicensingCached = null;
/** /**
* Get singleton instance * Get singleton instance
*/ */
@@ -84,6 +89,11 @@ final class PluginLicenseChecker
return true; return true;
} }
// Always valid when self-licensing (server URL points to this installation)
if ($this->isSelfLicensing()) {
return true;
}
// Check cache first // Check cache first
$cached = get_transient(self::CACHE_KEY); $cached = get_transient(self::CACHE_KEY);
if ($cached !== false) { if ($cached !== false) {
@@ -107,6 +117,11 @@ final class PluginLicenseChecker
return true; return true;
} }
// Always valid when self-licensing (server URL points to this installation)
if ($this->isSelfLicensing()) {
return true;
}
// Check settings are configured // Check settings are configured
$serverUrl = $this->getLicenseServerUrl(); $serverUrl = $this->getLicenseServerUrl();
$licenseKey = $this->getLicenseKey(); $licenseKey = $this->getLicenseKey();
@@ -176,6 +191,7 @@ final class PluginLicenseChecker
delete_transient(self::CACHE_KEY); delete_transient(self::CACHE_KEY);
delete_transient(self::ERROR_CACHE_KEY); delete_transient(self::ERROR_CACHE_KEY);
$this->isLocalhostCached = null; $this->isLocalhostCached = null;
$this->isSelfLicensingCached = null;
} }
/** /**
@@ -215,6 +231,60 @@ final class PluginLicenseChecker
return false; return false;
} }
/**
* Check if self-licensing (license server URL points to this installation)
*
* Prevents circular dependency where plugin tries to validate against itself.
* Plugins can only be validated against the original store from which they were obtained.
*/
public function isSelfLicensing(): bool
{
if ($this->isSelfLicensingCached !== null) {
return $this->isSelfLicensingCached;
}
$serverUrl = $this->getLicenseServerUrl();
// No server URL configured - not self-licensing
if (empty($serverUrl)) {
$this->isSelfLicensingCached = false;
return false;
}
// Parse both URLs to compare domains
$serverParsed = parse_url($serverUrl);
$siteUrl = get_site_url();
$siteParsed = parse_url($siteUrl);
// Get normalized domains (lowercase, no www prefix)
$serverDomain = $this->normalizeDomain($serverParsed['host'] ?? '');
$siteDomain = $this->normalizeDomain($siteParsed['host'] ?? '');
// If domains match, this is self-licensing
if ($serverDomain === $siteDomain) {
$this->isSelfLicensingCached = true;
return true;
}
$this->isSelfLicensingCached = false;
return false;
}
/**
* Normalize a domain for comparison (lowercase, strip www)
*/
private function normalizeDomain(string $domain): string
{
$domain = strtolower(trim($domain));
// Strip www. prefix
if (str_starts_with($domain, 'www.')) {
$domain = substr($domain, 4);
}
return $domain;
}
/** /**
* Get the current domain from the site URL * Get the current domain from the site URL
*/ */

View File

@@ -208,15 +208,50 @@ final class Plugin
return; return;
} }
// Try new multi-domain format first
$domainData = $order->get_meta('_licensed_product_domains');
if (!empty($domainData) && is_array($domainData)) {
$this->generateLicensesMultiDomain($order, $domainData);
return;
}
// Fall back to legacy single domain format
$this->generateLicensesSingleDomain($order);
}
/**
* Generate licenses for new multi-domain format
*/
private function generateLicensesMultiDomain(\WC_Order $order, array $domainData): void
{
$orderId = $order->get_id();
$customerId = $order->get_customer_id();
// Index domains by product ID for quick lookup
$domainsByProduct = [];
foreach ($domainData as $item) {
if (isset($item['product_id']) && isset($item['domains']) && is_array($item['domains'])) {
$domainsByProduct[(int) $item['product_id']] = $item['domains'];
}
}
// Generate licenses for each licensed product
foreach ($order->get_items() as $item) { foreach ($order->get_items() as $item) {
$product = $item->get_product(); $product = $item->get_product();
if ($product && $product->is_type('licensed')) { if (!$product || !$product->is_type('licensed')) {
$domain = $order->get_meta('_licensed_product_domain'); continue;
if ($domain) { }
$productId = $product->get_id();
$domains = $domainsByProduct[$productId] ?? [];
// Generate a license for each domain
foreach ($domains as $domain) {
if (!empty($domain)) {
$this->licenseManager->generateLicense( $this->licenseManager->generateLicense(
$orderId, $orderId,
$product->get_id(), $productId,
$order->get_customer_id(), $customerId,
$domain $domain
); );
} }
@@ -224,6 +259,29 @@ final class Plugin
} }
} }
/**
* Generate licenses for legacy single domain format
*/
private function generateLicensesSingleDomain(\WC_Order $order): void
{
$domain = $order->get_meta('_licensed_product_domain');
if (empty($domain)) {
return;
}
foreach ($order->get_items() as $item) {
$product = $item->get_product();
if ($product && $product->is_type('licensed')) {
$this->licenseManager->generateLicense(
$order->get_id(),
$product->get_id(),
$order->get_customer_id(),
$domain
);
}
}
}
/** /**
* Get Twig environment * Get Twig environment
*/ */

View File

@@ -1,81 +1,130 @@
{% if not has_licenses %} {% if not has_packages %}
<p>{{ __('You have no licenses yet.') }}</p> <p>{{ __('You have no licenses yet.') }}</p>
{% else %} {% else %}
<div class="woocommerce-licenses"> <div class="woocommerce-licenses">
{% for item in licenses %} {% for package in packages %}
<div class="license-card"> <div class="license-package">
<div class="license-header"> <div class="package-header">
<h3> <div class="package-title">
{% if item.product_url %} <h3>
<a href="{{ esc_url(item.product_url) }}">{{ esc_html(item.product_name) }}</a> {% if package.product_url %}
{% else %} <a href="{{ esc_url(package.product_url) }}">{{ esc_html(package.product_name) }}</a>
{{ esc_html(item.product_name) }} {% else %}
{% endif %} {{ esc_html(package.product_name) }}
</h3> {% endif %}
<span class="license-status license-status-{{ esc_attr(item.license.status) }}"> </h3>
{{ esc_html(item.license.status)|capitalize }} <span class="package-order">
{{ __('Order') }}
{% if package.order_url %}
<a href="{{ esc_url(package.order_url) }}">#{{ esc_html(package.order_number) }}</a>
{% else %}
#{{ esc_html(package.order_number) }}
{% endif %}
</span>
</div>
<span class="package-license-count">
{{ package.licenses|length }} {{ package.licenses|length == 1 ? __('License') : __('Licenses') }}
</span> </span>
</div> </div>
<div class="license-details"> <div class="package-licenses">
<div class="license-key-row"> {% for license in package.licenses %}
<label>{{ __('License Key:') }}</label> <div class="license-entry license-entry-{{ esc_attr(license.status) }}">
<code class="license-key" data-license-key="{{ esc_attr(item.license.licenseKey) }}"> <div class="license-row-primary">
{{ esc_html(item.license.licenseKey) }} <div class="license-key-group">
</code> <code class="license-key">{{ esc_html(license.license_key) }}</code>
<button type="button" class="copy-license-btn" data-license-key="{{ esc_attr(item.license.licenseKey) }}" title="{{ __('Copy to clipboard') }}"> <span class="license-status license-status-{{ esc_attr(license.status) }}">
<span class="dashicons dashicons-clipboard"></span> {{ esc_html(license.status)|capitalize }}
</button> </span>
</div> </div>
<div class="license-actions">
<div class="license-info-row"> <button type="button" class="copy-license-btn" data-license-key="{{ esc_attr(license.license_key) }}" title="{{ __('Copy to clipboard') }}">
<span class="license-domain-display" data-license-id="{{ item.license.id }}"> <span class="dashicons dashicons-clipboard"></span>
<strong>{{ __('Domain:') }}</strong> </button>
<span class="domain-value">{{ esc_html(item.license.domain) }}</span> {% if license.is_transferable %}
{% if item.license.status == 'active' or item.license.status == 'inactive' %} <button type="button" class="wclp-transfer-btn"
<button type="button" class="wclp-transfer-btn" data-license-id="{{ license.id }}"
data-license-id="{{ item.license.id }}" data-current-domain="{{ esc_attr(license.domain) }}"
data-current-domain="{{ esc_attr(item.license.domain) }}" title="{{ __('Transfer to new domain') }}">
title="{{ __('Transfer to new domain') }}"> <span class="dashicons dashicons-randomize"></span>
<span class="dashicons dashicons-randomize"></span> </button>
{{ __('Transfer') }} {% endif %}
</button> </div>
{% endif %} </div>
</span> <div class="license-row-secondary">
<span><strong>{{ __('Expires:') }}</strong> <span class="license-meta-item license-domain">
{% if item.license.expiresAt %} <span class="dashicons dashicons-admin-site-alt3"></span>
{{ item.license.expiresAt|date('Y-m-d') }} {{ esc_html(license.domain) }}
{% else %} </span>
{{ __('Never') }} <span class="license-meta-item license-expiry">
{% endif %} <span class="dashicons dashicons-calendar-alt"></span>
</span> {% if license.expires_at %}
</div> {{ license.expires_at|date('Y-m-d') }}
{% else %}
<span class="lifetime">{{ __('Lifetime') }}</span>
{% endif %}
</span>
</div>
</div>
{% endfor %}
</div> </div>
{% if item.downloads is defined and item.downloads is not empty %} {% if package.downloads is defined and package.downloads is not empty %}
<div class="license-downloads"> <div class="package-downloads">
<h4>{{ __('Available Downloads') }}</h4> <h4>{{ __('Available Downloads') }}</h4>
<ul class="download-list"> <ul class="download-list">
{% for download in item.downloads %} {# Show only the latest version (first item) #}
<li class="download-item"> {% set latest = package.downloads|first %}
<div class="download-row-file"> <li class="download-item download-item-latest">
<a href="{{ esc_url(download.download_url) }}" class="download-link"> <div class="download-row-file">
<span class="dashicons dashicons-download"></span> <a href="{{ esc_url(latest.download_url) }}" class="download-link">
{{ esc_html(download.filename ?: 'Version ' ~ download.version) }} <span class="dashicons dashicons-download"></span>
</a> {{ esc_html(latest.filename ?: 'Version ' ~ latest.version) }}
</div> </a>
<div class="download-row-meta"> <span class="download-version-badge">{{ __('Latest') }}</span>
<span class="download-date">{{ esc_html(download.released_at) }}</span> </div>
{% if download.file_hash %} <div class="download-row-meta">
<span class="download-hash" title="{{ esc_attr(download.file_hash) }}"> <span class="download-date">{{ esc_html(latest.released_at) }}</span>
<span class="dashicons dashicons-shield"></span> {% if latest.file_hash %}
<code>{{ download.file_hash[:12] }}...</code> <span class="download-hash" title="{{ esc_attr(latest.file_hash) }}">
</span> <span class="dashicons dashicons-shield"></span>
{% endif %} <code>{{ latest.file_hash[:12] }}...</code>
</div> </span>
</li> {% endif %}
{% endfor %} </div>
</li>
</ul> </ul>
{# Show older versions in collapsible if more than one version exists #}
{% if package.downloads|length > 1 %}
<div class="older-versions-section">
<button type="button" class="older-versions-toggle" aria-expanded="false">
<span class="dashicons dashicons-arrow-down-alt2"></span>
{{ __('Older versions') }} ({{ package.downloads|length - 1 }})
</button>
<ul class="download-list older-versions-list" style="display: none;">
{% for download in package.downloads|slice(1) %}
<li class="download-item">
<div class="download-row-file">
<a href="{{ esc_url(download.download_url) }}" class="download-link">
<span class="dashicons dashicons-download"></span>
{{ esc_html(download.filename ?: 'Version ' ~ download.version) }}
</a>
</div>
<div class="download-row-meta">
<span class="download-date">{{ esc_html(download.released_at) }}</span>
{% if download.file_hash %}
<span class="download-hash" title="{{ esc_attr(download.file_hash) }}">
<span class="dashicons dashicons-shield"></span>
<code>{{ download.file_hash[:12] }}...</code>
</span>
{% endif %}
</div>
</li>
{% endfor %}
</ul>
</div>
{% endif %}
</div> </div>
{% endif %} {% endif %}
</div> </div>

View File

@@ -3,7 +3,7 @@
* Plugin Name: WooCommerce Licensed Product * Plugin Name: WooCommerce Licensed Product
* Plugin URI: https://src.bundespruefstelle.ch/magdev/wc-licensed-product * Plugin URI: https://src.bundespruefstelle.ch/magdev/wc-licensed-product
* Description: WooCommerce plugin to sell software products using license keys with domain-based validation. * Description: WooCommerce plugin to sell software products using license keys with domain-based validation.
* Version: 0.3.9 * Version: 0.5.0
* Author: Marco Graetsch * Author: Marco Graetsch
* Author URI: https://src.bundespruefstelle.ch/magdev * Author URI: https://src.bundespruefstelle.ch/magdev
* License: GPL-2.0-or-later * License: GPL-2.0-or-later
@@ -28,7 +28,7 @@ if (!defined('ABSPATH')) {
} }
// Plugin constants // Plugin constants
define('WC_LICENSED_PRODUCT_VERSION', '0.3.9'); define('WC_LICENSED_PRODUCT_VERSION', '0.5.0');
define('WC_LICENSED_PRODUCT_PLUGIN_FILE', __FILE__); define('WC_LICENSED_PRODUCT_PLUGIN_FILE', __FILE__);
define('WC_LICENSED_PRODUCT_PLUGIN_DIR', plugin_dir_path(__FILE__)); define('WC_LICENSED_PRODUCT_PLUGIN_DIR', plugin_dir_path(__FILE__));
define('WC_LICENSED_PRODUCT_PLUGIN_URL', plugin_dir_url(__FILE__)); define('WC_LICENSED_PRODUCT_PLUGIN_URL', plugin_dir_url(__FILE__));