20 Commits

Author SHA1 Message Date
c31df1e8c4 Add licensed variable product support for duration-based licenses (v0.5.3)
Customers can now purchase licenses with different durations (monthly,
yearly, lifetime) through WooCommerce product variations. Each variation
can have its own license validity settings.

New features:
- LicensedVariableProduct class for variable licensed products
- LicensedProductVariation class for individual variations
- Per-variation license duration and max activations settings
- Duration labels in checkout (Monthly, Quarterly, Yearly, etc.)
- Full support for WooCommerce Blocks checkout with variations
- Updated translations for German (de_CH)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-26 16:14:15 +01:00
8cac742f57 Update CLAUDE.md with v0.5.2 session history
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-26 15:36:35 +01:00
41e46fc7b8 Bump version to 0.5.2 and update changelog
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-26 15:31:53 +01:00
549a58dc5d Add per-license customer secrets for API response verification
- Add static methods to ResponseSigner for deriving customer-specific secrets
- Display "API Verification Secret" in customer account licenses page
- Add collapsible secret section with copy button
- Update server-implementation.md with per-license secret documentation
- Update translations with new strings

Each customer now gets a unique verification secret derived from their
license key, eliminating the need to share the master server secret.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-26 15:29:57 +01:00
7d02105284 Update CLAUDE.md with v0.5.1 session history
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-26 15:20:35 +01:00
2207efbc52 Add release package v0.5.1
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-26 15:12:43 +01:00
3fe173686b Bump version to 0.5.1
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-26 15:10:23 +01:00
86b5bdb075 Fix version sorting and license actions visibility
- Sort product versions by version DESC when adding via AJAX
- Make license actions always visible in admin overview

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-26 15:09:42 +01:00
c6d6269ee3 Update translations for v0.5.1
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-26 15:09:25 +01:00
75f1dabdb4 Add roadmap placeholder sections for next versions
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-25 18:38:31 +01:00
8acde7cadd Update CLAUDE.md with v0.5.0 session history
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-25 18:37:28 +01:00
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
36 changed files with 5076 additions and 1657 deletions

View File

@@ -7,6 +7,105 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
## [0.5.3] - 2026-01-26
### Added
- Variable licensed product type (`licensed-variable`) for selling licenses with different durations
- Support for monthly, yearly, quarterly, or lifetime license variations
- `LicensedVariableProduct` class extending `WC_Product_Variable`
- `LicensedProductVariation` class for individual variation license settings
- Variation-specific license duration settings in product edit page
- Duration labels displayed in checkout domain fields (e.g., "Yearly License")
- Variation ID tracking in order domain meta for proper license generation
### Changed
- Updated `LicenseManager::generateLicense()` to accept optional variation ID
- Checkout now handles variations with separate domain fields per product/variation
- WooCommerce Blocks checkout updated to display variation duration labels
- Store API extension updated to include variation_id in domain data schema
## [0.5.2] - 2026-01-26
### Added
- Per-license customer secrets for API response verification
- "API Verification Secret" section in customer account licenses page (collapsible)
- Copy button for customer secrets with clipboard support
- Documentation for per-license secret derivation and usage
### Security
- Customers no longer need the master server secret for signature verification
- Each license key has a unique derived secret using HKDF-like key derivation
- If one customer's secret is compromised, other customers remain unaffected
### Changed
- Updated `ResponseSigner` with static methods for secret derivation
- Updated `server-implementation.md` with per-license secret documentation
- Added new translation strings for secret-related UI
## [0.5.1] - 2026-01-26
### Fixed
- Product versions now sort correctly by version DESC when added via AJAX in admin
- License actions in admin overview are now always visible instead of only on hover
### Changed
- Added `compareVersions()` JavaScript function for proper semantic version comparison
- Updated CSS with `!important` to override WordPress default hover-only behavior for row actions
## [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
### Added

273
CLAUDE.md
View File

@@ -32,17 +32,9 @@ This project is proudly **"vibe-coded"** using Claude.AI - the entire codebase w
**Note for AI Assistants:** Clean this section after the specific features are done or new releases are made. Effective changes are tracked in `CHANGELOG.md`. Do not add completed versions here - document them in the Session History section at the end of this file.
### Known Bugs
### Version 0.6.0
No known bugs at the moment.
### Version 0.3.9
No changes at the moment.
### Version 0.4.0
No changes at the moment.
*No planned features yet.*
## Technical Stack
@@ -1125,3 +1117,264 @@ 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)
- SHA256: `50ad6966c5ab8db2257572084d2d8a820448df62615678e1576696f2c0cb383d`
- 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
### 2026-01-25 - Version 0.5.0 - Multi-Domain Licensing
**Overview:**
Major feature release enabling customers to purchase multiple licenses for different domains in a single order. Each cart item quantity requires a unique domain at checkout.
**Implemented:**
- Multi-domain licensing support with new setting "Enable Multi-Domain Licensing"
- Multi-domain checkout UI for both classic checkout and WooCommerce Blocks
- Grouped license display in customer account page by product/order (package view)
- "Older versions" collapsible section in customer download area
- Updated email templates to show licenses grouped by product
- DOM injection fallback for WooCommerce Blocks when React component fails
**New Setting:**
- `wclp_enable_multi_domain` - Enable/disable multi-domain licensing mode
**New Order Meta:**
- `_licensed_product_domains` - Array of domain data for multi-domain orders:
```php
[
['product_id' => 123, 'domains' => ['site1.com', 'site2.com']],
['product_id' => 456, 'domains' => ['another.com']],
]
```
**Modified files:**
- `src/Admin/SettingsController.php` - Added multi-domain setting
- `src/Checkout/CheckoutController.php` - Multi-domain field rendering and validation
- `src/Checkout/CheckoutBlocksIntegration.php` - WooCommerce Blocks multi-domain support
- `src/Checkout/StoreApiExtension.php` - Multi-domain data handling in Store API
- `src/Frontend/AccountController.php` - Grouped license display by product
- `src/Email/LicenseEmailController.php` - Grouped license email templates
- `src/Plugin.php` - Multi-domain license generation
- `src/License/LicenseManager.php` - Multi-domain license creation
- `src/Admin/OrderLicenseController.php` - Multi-domain order display
- `assets/js/checkout-blocks.js` - Complete rewrite for ExperimentalOrderMeta slot
- `assets/js/frontend.js` - Older versions toggle functionality
- `assets/css/frontend.css` - Package-based layout styles
- `templates/frontend/licenses.html.twig` - Grouped license template
**Technical notes:**
- WooCommerce Blocks integration uses `ExperimentalOrderMeta` slot with `registerPlugin`
- DOM injection fallback activates after 2 seconds if React component fails to render
- Multi-domain validation ensures unique domains per product
- Backward compatible: existing single-domain orders continue to work
- New `getLicensesByOrderAndProduct()` method returns all licenses for a product in an order
- Customer account groups licenses by product for package-style display
- Email templates show licenses in table format grouped by product
**Bug Fix:**
- Fixed: Domain fields not rendering in WooCommerce Blocks checkout
- Root cause: `registerCheckoutBlock` approach requires manual block editor configuration
- Fix: Switched to `ExperimentalOrderMeta` slot pattern with `registerPlugin` + DOM injection fallback
**Translation Updates:**
- Added 19 new strings for multi-domain functionality
- Fixed all fuzzy translations in German (de_CH)
- Updated .pot template and compiled .mo files
**Release v0.5.0:**
- Created release package: `releases/wc-licensed-product-0.5.0.zip` (863 KB)
- SHA256: `446804948e5f99d705b548061d5b78180856984c58458640a910ada8f27f5316`
- Tagged as `v0.5.0` and pushed to `main` branch
### 2026-01-26 - Version 0.5.1 - Admin UI Fixes
**Overview:**
Bug fix release improving admin UI usability for version management and license overview.
**Bug Fixes:**
- Fixed: Product versions in admin now sort by version DESC when adding via AJAX
- Fixed: License actions in admin overview are now always visible (not just on hover)
**Modified files:**
- `assets/css/admin.css` - Added `!important` to `.licenses-table .row-actions` for permanent visibility
- `assets/js/versions.js` - Added `compareVersions()` function and sorted insertion for AJAX-added versions
**Technical notes:**
- Version sorting uses semantic version comparison (major.minor.patch)
- New versions are inserted in correct sorted position in the table instead of always appending
- CSS override uses `!important` to overcome WordPress default hover-only behavior for row actions
- `compareVersions()` function compares version strings numerically (1.10.0 > 1.9.0)
**Release v0.5.1:**
- Created release package: `releases/wc-licensed-product-0.5.1.zip` (863 KB)
- SHA256: `a489f0b8cfcd7d5d9b2021b7ff581b9f1a56468dfde87bbb06bb4555d11f7556`
- Tagged as `v0.5.1` and pushed to `main` branch
### 2026-01-26 - Version 0.5.2 - Per-License Customer Secrets
**Overview:**
Security enhancement release adding per-license customer secrets for API response verification. Each customer now receives a unique secret derived from their license key, eliminating the need to share a global server secret.
**Implemented:**
- Per-license secret derivation using HKDF-like approach
- Customer account UI showing API verification secret with collapsible section
- Copy-to-clipboard functionality for customer secrets
- Static helper methods in ResponseSigner for secret derivation
**New methods in ResponseSigner:**
- `deriveCustomerSecret()` - Static method to derive customer secret from license key and server secret
- `getCustomerSecretForLicense()` - Static method to get customer secret using configured server secret
- `isSigningEnabled()` - Static method to check if response signing is configured
**Modified files:**
- `src/Api/ResponseSigner.php` - Added static methods for customer secret derivation
- `src/Frontend/AccountController.php` - Added `signing_enabled` and `customer_secret` to template data
- `templates/frontend/licenses.html.twig` - Added collapsible secret section with toggle and copy button
- `assets/css/frontend.css` - Added styles for `.license-row-secret`, `.secret-toggle`, `.secret-content`
- `assets/js/frontend.js` - Added `toggleSecret()` and `copySecret()` event handlers
- `docs/server-implementation.md` - Added documentation for per-license secrets
**Technical notes:**
- Secret derivation uses HKDF-like approach: `HMAC-SHA256(HMAC-SHA256(license_key, server_secret) + "\x01", server_secret)`
- Each license gets a unique 64-character hex secret
- Secrets are only shown when `WC_LICENSE_SERVER_SECRET` is configured
- Collapsible UI prevents accidental secret exposure
- If server secret is rotated, all customer secrets change automatically
**Security improvement:**
- Customers no longer need access to the master `WC_LICENSE_SERVER_SECRET`
- If one customer's secret is leaked, other customers are not affected
- Each license key derives its own unique verification secret
**Release v0.5.2:**
- Created release package: `releases/wc-licensed-product-0.5.2.zip` (845 KB)
- SHA256: `2d61a78ac5ba0f1d115a6401e6dded5b872b18f5530027c371604cbd18e9e27c`
- Tagged as `v0.5.2` and pushed to `main` branch
### 2026-01-26 - Version 0.5.3 - Variable Licensed Products
**Overview:**
Major feature release adding support for WooCommerce variable products. Customers can now purchase licenses with different durations (monthly, yearly, lifetime) as product variations.
**New files:**
- `src/Product/LicensedVariableProduct.php` - Variable product class extending `WC_Product_Variable`
- `src/Product/LicensedProductVariation.php` - Variation class with license settings
**Implemented:**
- New `licensed-variable` product type for selling licenses with different durations
- `LicensedVariableProduct` class extending WooCommerce variable products
- `LicensedProductVariation` class for individual variation license settings
- Variation-specific license duration fields in product edit page (days, max activations)
- Duration labels (Monthly, Quarterly, Yearly, Lifetime) displayed in checkout
- Variation ID tracking in order domain meta for proper license generation
- WooCommerce Blocks checkout updated to handle variations with duration labels
**Modified files:**
- `src/Product/LicensedProductType.php` - Added licensed-variable type registration, variation hooks
- `src/License/LicenseManager.php` - Added `isLicensedProduct()` helper, variation support in `generateLicense()`
- `src/Plugin.php` - Updated license generation to handle variations
- `src/Checkout/CheckoutController.php` - Variation support in domain field rendering
- `src/Checkout/CheckoutBlocksIntegration.php` - Variation data in blocks checkout
- `src/Checkout/StoreApiExtension.php` - Variation ID in Store API schema
- `assets/js/checkout-blocks.js` - Variation handling in React components and DOM fallback
**Technical notes:**
- Variable product type shows in WooCommerce product type selector as "Licensed Variable Product"
- Each variation can override parent's license duration and max activations
- Variations are always virtual (licensed products don't ship)
- `LicensedProductVariation::get_license_duration_label()` returns human-readable duration
- Order meta `_licensed_product_domains` now includes optional `variation_id` field
- License generation uses variation settings when `variation_id` is present in order item
- Backward compatible: existing simple licensed products continue to work unchanged

View File

@@ -201,7 +201,8 @@ code.file-hash {
}
.licenses-table .row-actions {
visibility: visible;
visibility: visible !important;
position: static !important;
padding: 2px 0 0;
}

View File

@@ -37,13 +37,196 @@
color: #383d41;
}
/* License Cards */
/* License Packages */
.woocommerce-licenses {
display: flex;
flex-direction: column;
gap: 1.5em;
}
.license-package {
border: 1px solid #e5e5e5;
border-radius: 8px;
overflow: hidden;
background: #fff;
}
.package-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1em 1.5em;
background: #f8f9fa;
border-bottom: 1px solid #e5e5e5;
}
.package-title {
display: flex;
flex-direction: column;
gap: 0.25em;
}
.package-title h3 {
margin: 0;
font-size: 1.1em;
}
.package-title h3 a {
color: inherit;
text-decoration: none;
}
.package-title h3 a:hover {
text-decoration: underline;
}
.package-order {
font-size: 0.85em;
color: #666;
}
.package-order a {
color: #2271b1;
text-decoration: none;
}
.package-order a:hover {
text-decoration: underline;
}
.package-license-count {
font-size: 0.9em;
color: #666;
background: #e9ecef;
padding: 0.3em 0.8em;
border-radius: 12px;
}
/* Package Licenses - Two Row Layout */
.package-licenses {
padding: 0;
}
.license-entry {
padding: 1em 1.5em;
border-bottom: 1px solid #e5e5e5;
}
.license-entry:last-child {
border-bottom: none;
}
.license-entry:hover {
background-color: #fafafa;
}
.license-row-primary {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1em;
margin-bottom: 0.5em;
}
.license-key-group {
display: flex;
align-items: center;
gap: 0.75em;
flex-shrink: 1;
min-width: 0;
}
.license-entry code.license-key {
font-family: 'SF Mono', Monaco, Consolas, monospace;
background-color: #f5f5f5;
padding: 0.4em 0.75em;
border-radius: 4px;
font-size: 0.95em;
letter-spacing: 0.03em;
flex-shrink: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
}
.license-key-group .license-status {
flex-shrink: 0;
}
.license-actions {
display: flex;
align-items: center;
gap: 0.5em;
flex-shrink: 0;
}
.license-row-secondary {
display: flex;
align-items: center;
gap: 1.5em;
font-size: 0.9em;
color: #666;
flex-wrap: wrap;
}
.license-meta-item {
display: inline-flex;
align-items: center;
gap: 0.35em;
}
.license-meta-item .dashicons {
font-size: 14px;
width: 14px;
height: 14px;
color: #999;
}
.license-domain {
color: #333;
}
.license-expiry .lifetime {
color: #28a745;
font-weight: 500;
}
/* Legacy table styles (kept for backwards compatibility) */
.licenses-table {
width: 100%;
border-collapse: collapse;
font-size: 0.95em;
}
.licenses-table th,
.licenses-table td {
padding: 0.75em 1em;
text-align: left;
border-bottom: 1px solid #e5e5e5;
}
.licenses-table th {
font-weight: 600;
background-color: #fafafa;
font-size: 0.9em;
color: #555;
}
.licenses-table code.license-key {
font-family: 'SF Mono', Monaco, Consolas, monospace;
background-color: #f5f5f5;
padding: 0.3em 0.6em;
border-radius: 4px;
font-size: 0.9em;
letter-spacing: 0.03em;
}
.licenses-table .lifetime {
color: #28a745;
font-weight: 500;
}
/* Legacy single card styles (kept for backwards compatibility) */
.license-card {
border: 1px solid #e5e5e5;
border-radius: 8px;
@@ -184,12 +367,14 @@
}
/* Download Section */
.package-downloads,
.license-downloads {
padding: 1em 1.5em;
background: #f8f9fa;
border-top: 1px solid #e5e5e5;
}
.package-downloads h4,
.license-downloads h4 {
margin: 0 0 0.75em 0;
font-size: 0.95em;
@@ -282,6 +467,71 @@
color: #666;
}
/* Latest version badge */
.download-version-badge {
display: inline-block;
padding: 0.15em 0.5em;
margin-left: 0.5em;
font-size: 0.75em;
font-weight: 600;
text-transform: uppercase;
background: #d4edda;
color: #155724;
border-radius: 3px;
vertical-align: middle;
}
/* Older versions collapsible */
.older-versions-section {
margin-top: 0.75em;
padding-top: 0.75em;
border-top: 1px dashed #ddd;
}
.older-versions-toggle {
display: inline-flex;
align-items: center;
gap: 0.35em;
padding: 0.4em 0.75em;
background: transparent;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 0.85em;
color: #666;
cursor: pointer;
transition: all 0.2s ease;
}
.older-versions-toggle:hover {
background: #f5f5f5;
border-color: #ccc;
color: #333;
}
.older-versions-toggle .dashicons {
font-size: 16px;
width: 16px;
height: 16px;
transition: transform 0.2s ease;
}
.older-versions-toggle[aria-expanded="true"] .dashicons {
transform: rotate(180deg);
}
.older-versions-list {
margin-top: 0.75em;
padding-left: 0;
}
.older-versions-list .download-item {
opacity: 0.85;
}
.older-versions-list .download-item:hover {
opacity: 1;
}
/* Domain Field */
#licensed-product-domain-field {
margin-top: 2em;
@@ -333,6 +583,52 @@
/* Responsive */
@media screen and (max-width: 768px) {
/* Package header responsive */
.package-header {
flex-direction: column;
align-items: flex-start;
gap: 0.75em;
}
.package-license-count {
align-self: flex-start;
}
/* License entry responsive */
.license-entry {
padding: 1em;
}
.license-row-primary {
flex-direction: column;
align-items: flex-start;
gap: 0.75em;
}
.license-key-group {
flex-direction: column;
align-items: flex-start;
gap: 0.5em;
width: 100%;
}
.license-entry code.license-key {
font-size: 0.85em;
word-break: break-all;
white-space: normal;
}
.license-actions {
align-self: flex-start;
}
.license-row-secondary {
flex-direction: column;
align-items: flex-start;
gap: 0.5em;
}
/* Legacy card layout responsive */
.license-header {
flex-direction: column;
align-items: flex-start;
@@ -354,33 +650,44 @@
flex-wrap: wrap;
}
/* Legacy table responsive */
.woocommerce-licenses-table,
.woocommerce-licenses-table thead,
.woocommerce-licenses-table tbody,
.woocommerce-licenses-table th,
.woocommerce-licenses-table td,
.woocommerce-licenses-table tr {
.woocommerce-licenses-table tr,
.licenses-table,
.licenses-table thead,
.licenses-table tbody,
.licenses-table th,
.licenses-table td,
.licenses-table tr {
display: block;
}
.woocommerce-licenses-table thead tr {
.woocommerce-licenses-table thead tr,
.licenses-table thead tr {
position: absolute;
top: -9999px;
left: -9999px;
}
.woocommerce-licenses-table tr {
.woocommerce-licenses-table tr,
.licenses-table tr {
border: 1px solid #e5e5e5;
margin-bottom: 1em;
}
.woocommerce-licenses-table td {
.woocommerce-licenses-table td,
.licenses-table td {
border: none;
position: relative;
padding-left: 50%;
}
.woocommerce-licenses-table td:before {
.woocommerce-licenses-table td:before,
.licenses-table td:before {
content: attr(data-title);
position: absolute;
left: 0.75em;
@@ -556,3 +863,118 @@
color: #2271b1;
font-weight: 500;
}
/* Customer Secret Section */
.license-row-secret {
margin-top: 0.75em;
padding-top: 0.75em;
border-top: 1px dashed #e5e5e5;
}
.secret-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;
}
.secret-toggle:hover {
background: #f5f5f5;
border-color: #ccc;
color: #333;
}
.secret-toggle .dashicons {
font-size: 14px;
width: 14px;
height: 14px;
}
.secret-toggle .toggle-arrow {
transition: transform 0.2s ease;
}
.secret-toggle[aria-expanded="true"] .toggle-arrow {
transform: rotate(180deg);
}
.secret-content {
margin-top: 0.75em;
padding: 1em;
background: #f8f9fa;
border-radius: 4px;
border: 1px solid #e5e5e5;
}
.secret-description {
margin: 0 0 0.75em 0;
font-size: 0.85em;
color: #666;
}
.secret-value-wrapper {
display: flex;
align-items: center;
gap: 0.5em;
}
.secret-value {
font-family: 'SF Mono', Monaco, Consolas, monospace;
font-size: 0.75em;
background: #fff;
padding: 0.5em 0.75em;
border: 1px solid #ddd;
border-radius: 4px;
word-break: break-all;
flex: 1;
min-width: 0;
overflow-x: auto;
}
.copy-secret-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
padding: 0;
background: #f0f0f0;
border: 1px solid #ddd;
border-radius: 4px;
cursor: pointer;
transition: all 0.2s ease;
flex-shrink: 0;
}
.copy-secret-btn:hover {
background: #e5e5e5;
border-color: #ccc;
}
.copy-secret-btn .dashicons {
font-size: 18px;
width: 18px;
height: 18px;
}
@media screen and (max-width: 768px) {
.secret-value-wrapper {
flex-direction: column;
align-items: stretch;
}
.secret-value {
font-size: 0.7em;
}
.copy-secret-btn {
align-self: flex-start;
}
}

View File

@@ -1,7 +1,8 @@
/**
* WooCommerce Checkout Blocks Integration
*
* Adds a domain field to the checkout block for licensed products.
* Adds domain fields to the checkout block for licensed products.
* Supports single domain mode (legacy) and multi-domain mode (per quantity).
*
* @package WcLicensedProduct
*/
@@ -9,92 +10,375 @@
(function () {
'use strict';
const { registerCheckoutBlock } = wc.blocksCheckout;
const { createElement, useState, useEffect } = wp.element;
// Check dependencies
if (typeof wc === 'undefined' ||
typeof wc.blocksCheckout === 'undefined' ||
typeof wc.wcSettings === 'undefined') {
return;
}
const { getSetting } = wc.wcSettings;
const { createElement, useState } = wp.element;
const { TextControl } = wp.components;
const { __ } = wp.i18n;
const { extensionCartUpdate } = wc.blocksCheckout;
const { getSetting } = wc.wcSettings;
// Get settings passed from PHP
// Get available exports from blocksCheckout
const { ExperimentalOrderMeta } = wc.blocksCheckout;
// Get settings from PHP
const settings = getSetting('wc-licensed-product_data', {});
// Check if we have licensed products
if (!settings.hasLicensedProducts) {
return;
}
/**
* Validate domain format
*/
function isValidDomain(domain) {
if (!domain || domain.length > 255) {
return false;
}
if (!domain || domain.length > 255) return false;
const pattern = /^([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}$/;
return pattern.test(domain);
}
/**
* Normalize domain (remove protocol and www)
* Normalize domain
*/
function normalizeDomain(domain) {
let normalized = domain.toLowerCase().trim();
normalized = normalized.replace(/^https?:\/\//, '');
normalized = normalized.replace(/^www\./, '');
normalized = normalized.replace(/\/.*$/, '');
return normalized;
return domain.toLowerCase().trim()
.replace(/^https?:\/\//, '')
.replace(/^www\./, '')
.replace(/\/.*$/, '');
}
/**
* License Domain Block Component
* Single Domain Component
*/
const LicenseDomainBlock = ({ checkoutExtensionData, extensions }) => {
const SingleDomainField = () => {
const [domain, setDomain] = useState('');
const [error, setError] = useState('');
const { setExtensionData } = checkoutExtensionData;
// Only show if cart has licensed products
if (!settings.hasLicensedProducts) {
return null;
}
const handleChange = (value) => {
const normalized = normalizeDomain(value);
setDomain(normalized);
// Validate
if (normalized && !isValidDomain(normalized)) {
setError(settings.validationError || __('Please enter a valid domain.', 'wc-licensed-product'));
} else {
setError('');
}
// Update extension data for server-side processing
setExtensionData('wc-licensed-product', 'licensed_product_domain', normalized);
// Store in hidden input for form submission
const hiddenInput = document.getElementById('wclp-domain-hidden');
if (hiddenInput) {
hiddenInput.value = normalized;
}
};
return createElement(
'div',
{ className: 'wc-block-components-licensed-product-domain' },
createElement(
'h3',
{ className: 'wc-block-components-title' },
{
className: 'wc-block-components-licensed-product-domain',
style: {
padding: '16px',
backgroundColor: '#f0f0f0',
borderRadius: '4px',
marginBottom: '16px',
}
},
createElement('h4', { style: { marginTop: 0, marginBottom: '8px' } },
settings.sectionTitle || __('License Domain', 'wc-licensed-product')
),
createElement('p', { style: { marginBottom: '12px', color: '#666', fontSize: '0.9em' } },
settings.fieldDescription || __('Enter the domain where you will use the license.', 'wc-licensed-product')
),
createElement(TextControl, {
label: settings.fieldLabel || __('Domain for License Activation', 'wc-licensed-product'),
label: settings.singleDomainLabel || __('Domain', 'wc-licensed-product'),
value: domain,
onChange: handleChange,
placeholder: settings.fieldPlaceholder || 'example.com',
help: error || settings.fieldDescription || __('Enter the domain where you will use this license.', 'wc-licensed-product'),
help: error || '',
className: error ? 'has-error' : '',
required: true,
}),
createElement('input', {
type: 'hidden',
id: 'wclp-domain-hidden',
name: 'wclp_license_domain',
value: domain,
})
);
};
// Register the checkout block
registerCheckoutBlock({
metadata: {
name: 'wc-licensed-product/domain-field',
parent: ['woocommerce/checkout-contact-information-block'],
},
component: LicenseDomainBlock,
});
/**
* Get unique key for product (handles variations)
*/
function getProductKey(product) {
if (product.variation_id && product.variation_id > 0) {
return `${product.product_id}_${product.variation_id}`;
}
return String(product.product_id);
}
/**
* Multi-Domain Component
*/
const MultiDomainFields = () => {
const products = settings.licensedProducts || [];
const [domains, setDomains] = useState(() => {
const init = {};
products.forEach(p => {
const key = getProductKey(p);
init[key] = Array(p.quantity).fill('');
});
return init;
});
const [errors, setErrors] = useState({});
if (!products.length) {
return null;
}
const handleChange = (productKey, index, value) => {
const normalized = normalizeDomain(value);
const newDomains = { ...domains };
if (!newDomains[productKey]) newDomains[productKey] = [];
newDomains[productKey] = [...newDomains[productKey]];
newDomains[productKey][index] = normalized;
setDomains(newDomains);
// Validate
const key = `${productKey}_${index}`;
const newErrors = { ...errors };
if (normalized && !isValidDomain(normalized)) {
newErrors[key] = settings.validationError || __('Please enter a valid domain.', 'wc-licensed-product');
} else {
delete newErrors[key];
}
// Check for duplicates within same product/variation
const productDomains = newDomains[productKey].filter(d => d);
const uniqueDomains = new Set(productDomains.map(d => normalizeDomain(d)));
if (productDomains.length !== uniqueDomains.size) {
const seen = new Set();
newDomains[productKey].forEach((d, idx) => {
const normalizedD = normalizeDomain(d);
const dupKey = `${productKey}_${idx}`;
if (normalizedD && seen.has(normalizedD)) {
newErrors[dupKey] = settings.duplicateError || __('Each license requires a unique domain.', 'wc-licensed-product');
} else if (normalizedD) {
seen.add(normalizedD);
}
});
}
setErrors(newErrors);
// Update hidden field with variation support
const data = products.map(p => {
const pKey = getProductKey(p);
const doms = newDomains[pKey] || [];
const entry = {
product_id: p.product_id,
domains: doms.filter(d => d),
};
if (p.variation_id && p.variation_id > 0) {
entry.variation_id = p.variation_id;
}
return entry;
}).filter(item => item.domains.length > 0);
const hiddenInput = document.getElementById('wclp-domains-hidden');
if (hiddenInput) {
hiddenInput.value = JSON.stringify(data);
}
};
return createElement(
'div',
{
className: 'wc-block-components-licensed-product-domains',
style: {
padding: '16px',
backgroundColor: '#f0f0f0',
borderRadius: '4px',
marginBottom: '16px',
}
},
createElement('h4', { style: { marginTop: 0, marginBottom: '8px' } },
settings.sectionTitle || __('License Domains', 'wc-licensed-product')
),
createElement('p', { style: { marginBottom: '12px', color: '#666', fontSize: '0.9em' } },
settings.fieldDescription || __('Enter a unique domain for each license.', 'wc-licensed-product')
),
products.map(product => {
const productKey = getProductKey(product);
const durationLabel = product.duration_label || '';
const displayName = durationLabel
? `${product.name} (${durationLabel})`
: product.name;
return createElement(
'div',
{
key: productKey,
style: {
marginBottom: '16px',
padding: '12px',
backgroundColor: '#fff',
borderRadius: '4px',
}
},
createElement('strong', { style: { display: 'block', marginBottom: '8px' } },
displayName + (product.quantity > 1 ? ` ×${product.quantity}` : '')
),
Array.from({ length: product.quantity }, (_, i) => {
const key = `${productKey}_${i}`;
return createElement(
'div',
{ key: i, style: { marginBottom: '8px' } },
createElement(TextControl, {
label: (settings.licenseLabel || __('License %d:', 'wc-licensed-product')).replace('%d', i + 1),
value: domains[productKey]?.[i] || '',
onChange: (val) => handleChange(productKey, i, val),
placeholder: settings.fieldPlaceholder || 'example.com',
help: errors[key] || '',
})
);
})
);
}),
createElement('input', {
type: 'hidden',
id: 'wclp-domains-hidden',
name: 'wclp_license_domains',
value: '',
})
);
};
/**
* Main License Domains Block
*/
const LicenseDomainsBlock = () => {
if (settings.isMultiDomainEnabled) {
return createElement(MultiDomainFields);
}
return createElement(SingleDomainField);
};
// Register using ExperimentalOrderMeta slot
if (ExperimentalOrderMeta) {
const { registerPlugin } = wp.plugins || {};
if (registerPlugin) {
registerPlugin('wc-licensed-product-domain-fields', {
render: () => createElement(
ExperimentalOrderMeta,
{},
createElement(LicenseDomainsBlock)
),
scope: 'woocommerce-checkout',
});
}
}
// Fallback: inject into DOM directly if React approach fails
setTimeout(function() {
const existingComponent = document.querySelector('.wc-block-components-licensed-product-domain, .wc-block-components-licensed-product-domains');
if (existingComponent) {
return;
}
const checkoutForm = document.querySelector('.wc-block-checkout, .wc-block-checkout__form, form.checkout');
if (!checkoutForm) {
return;
}
const contactInfo = document.querySelector('.wc-block-checkout__contact-fields, .wp-block-woocommerce-checkout-contact-information-block');
const paymentMethods = document.querySelector('.wc-block-checkout__payment-method, .wp-block-woocommerce-checkout-payment-block');
let insertionPoint = contactInfo || paymentMethods;
if (!insertionPoint) {
insertionPoint = checkoutForm.querySelector('.wc-block-components-form');
}
if (!insertionPoint) {
return;
}
const container = document.createElement('div');
container.id = 'wclp-domain-fields-container';
container.className = 'wc-block-components-licensed-product-wrapper';
container.style.cssText = 'margin: 20px 0; padding: 16px; background: #f0f0f0; border-radius: 4px;';
if (settings.isMultiDomainEnabled && settings.licensedProducts) {
container.innerHTML = `
<h4 style="margin: 0 0 8px 0;">${settings.sectionTitle || 'License Domains'}</h4>
<p style="margin-bottom: 12px; color: #666; font-size: 0.9em;">
${settings.fieldDescription || 'Enter a unique domain for each license.'}
</p>
${settings.licensedProducts.map(product => {
const productKey = product.variation_id && product.variation_id > 0
? `${product.product_id}_${product.variation_id}`
: product.product_id;
const durationLabel = product.duration_label || '';
const displayName = durationLabel
? `${product.name} (${durationLabel})`
: product.name;
return `
<div style="margin-bottom: 16px; padding: 12px; background: #fff; border-radius: 4px;">
<strong style="display: block; margin-bottom: 8px;">
${displayName}${product.quantity > 1 ? ` ×${product.quantity}` : ''}
</strong>
${Array.from({ length: product.quantity }, (_, i) => `
<div style="margin-bottom: 8px;">
<label style="display: block; margin-bottom: 4px;">
${(settings.licenseLabel || 'License %d:').replace('%d', i + 1)}
</label>
<input type="text"
name="licensed_domains[${productKey}][${i}]"
placeholder="${settings.fieldPlaceholder || 'example.com'}"
style="width: 100%; padding: 8px; border: 1px solid #ccc; border-radius: 4px;"
/>
${product.variation_id && product.variation_id > 0 ? `
<input type="hidden"
name="licensed_variation_ids[${productKey}]"
value="${product.variation_id}"
/>
` : ''}
</div>
`).join('')}
</div>
`}).join('')}
`;
} else {
container.innerHTML = `
<h4 style="margin: 0 0 8px 0;">${settings.sectionTitle || 'License Domain'}</h4>
<p style="margin-bottom: 12px; color: #666; font-size: 0.9em;">
${settings.fieldDescription || 'Enter the domain where you will use the license.'}
</p>
<div style="margin-bottom: 8px;">
<label style="display: block; margin-bottom: 4px;">
${settings.singleDomainLabel || 'Domain'}
</label>
<input type="text"
name="licensed_product_domain"
placeholder="${settings.fieldPlaceholder || 'example.com'}"
style="width: 100%; padding: 8px; border: 1px solid #ccc; border-radius: 4px;"
/>
</div>
`;
}
if (contactInfo) {
contactInfo.parentNode.insertBefore(container, contactInfo.nextSibling);
} else if (paymentMethods) {
paymentMethods.parentNode.insertBefore(container, paymentMethods);
} else {
insertionPoint.appendChild(container);
}
}, 2000);
})();

View File

@@ -19,12 +19,19 @@
bindEvents: function() {
$(document).on('click', '.copy-license-btn', this.copyLicenseKey);
$(document).on('click', '.copy-secret-btn', this.copySecret);
// Transfer modal events
$(document).on('click', '.wclp-transfer-btn', this.openTransferModal.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));
// Older versions toggle
$(document).on('click', '.older-versions-toggle', this.toggleOlderVersions);
// Secret toggle
$(document).on('click', '.secret-toggle', this.toggleSecret);
// Close modal on escape key
$(document).on('keyup', function(e) {
if (e.key === 'Escape') {
@@ -33,6 +40,61 @@
});
},
/**
* 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);
},
/**
* Toggle secret visibility
*/
toggleSecret: function(e) {
e.preventDefault();
var $btn = $(this);
var $content = $btn.siblings('.secret-content');
var isExpanded = $btn.attr('aria-expanded') === 'true';
$btn.attr('aria-expanded', !isExpanded);
$content.slideToggle(200);
},
/**
* Copy secret to clipboard
*/
copySecret: function(e) {
e.preventDefault();
var $btn = $(this);
var secret = $btn.data('secret');
if (!secret) {
return;
}
// Use modern clipboard API if available
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(secret)
.then(function() {
WCLicensedProductFrontend.showCopyFeedback($btn, true);
})
.catch(function() {
WCLicensedProductFrontend.fallbackCopy(secret, $btn);
});
} else {
WCLicensedProductFrontend.fallbackCopy(secret, $btn);
}
},
/**
* Copy license key to clipboard
*/

View File

@@ -174,6 +174,24 @@
});
},
/**
* Compare two semantic version strings
* Returns: positive if a > b, negative if a < b, 0 if equal
*/
compareVersions: function(a, b) {
var partsA = a.split('.').map(Number);
var partsB = b.split('.').map(Number);
for (var i = 0; i < 3; i++) {
var numA = partsA[i] || 0;
var numB = partsB[i] || 0;
if (numA !== numB) {
return numA - numB;
}
}
return 0;
},
/**
* Extract version from filename
* Supports patterns like: plugin-v1.2.3.zip, plugin-1.2.3.zip, v1.2.3.zip
@@ -244,8 +262,23 @@
// Remove "no versions" row if present
$('#versions-table tbody .no-versions').remove();
// Add new row to table
$('#versions-table tbody').prepend(response.data.html);
// Add new row in sorted position (by version DESC)
var $newRow = $(response.data.html);
var newVersion = (response.data.version && response.data.version.version) || version;
var inserted = false;
$('#versions-table tbody tr').each(function() {
var rowVersion = $(this).find('td:first strong').text();
if (self.compareVersions(newVersion, rowVersion) > 0) {
$newRow.insertBefore($(this));
inserted = true;
return false; // break
}
});
if (!inserted) {
$('#versions-table tbody').append($newRow);
}
// Clear form
$('#new_version').val('');

12
composer.lock generated
View File

@@ -380,16 +380,16 @@
},
{
"name": "symfony/http-client",
"version": "v7.4.3",
"version": "v7.4.4",
"source": {
"type": "git",
"url": "https://github.com/symfony/http-client.git",
"reference": "d01dfac1e0dc99f18da48b18101c23ce57929616"
"reference": "d63c23357d74715a589454c141c843f0172bec6c"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/http-client/zipball/d01dfac1e0dc99f18da48b18101c23ce57929616",
"reference": "d01dfac1e0dc99f18da48b18101c23ce57929616",
"url": "https://api.github.com/repos/symfony/http-client/zipball/d63c23357d74715a589454c141c843f0172bec6c",
"reference": "d63c23357d74715a589454c141c843f0172bec6c",
"shasum": ""
},
"require": {
@@ -457,7 +457,7 @@
"http"
],
"support": {
"source": "https://github.com/symfony/http-client/tree/v7.4.3"
"source": "https://github.com/symfony/http-client/tree/v7.4.4"
},
"funding": [
{
@@ -477,7 +477,7 @@
"type": "tidelift"
}
],
"time": "2025-12-23T14:50:43+00:00"
"time": "2026-01-23T16:34:22+00:00"
},
{
"name": "symfony/http-client-contracts",

View File

@@ -8,14 +8,16 @@ The security model works as follows:
1. Server generates a unique signature for each response using HMAC-SHA256
2. Signature includes a timestamp to prevent replay attacks
3. Client verifies the signature using a shared secret
4. Invalid signatures cause the client to reject the response
3. Each license key has a unique derived secret (not the master secret)
4. Client verifies the signature using their per-license secret
5. Invalid signatures cause the client to reject the response
This prevents attackers from:
- Faking valid license responses
- Replaying old responses
- Tampering with response data
- Using one customer's secret to verify another customer's responses
## Requirements
@@ -323,13 +325,49 @@ Adjust if needed:
$signature = new ResponseSignature($key, timestampTolerance: 600); // 10 minutes
```
### Per-License Secrets
Each customer receives a unique secret derived from their license key. This means:
- Customers only know their own secret, not the master server secret
- If one customer's secret is leaked, other customers are not affected
- The server uses HKDF-like derivation to create unique secrets
#### How Customers Get Their Secret
Customers can find their per-license verification secret in their account:
1. Log in to the store
2. Go to My Account > Licenses
3. Click "API Verification Secret" under any license
4. Copy the 64-character hex string
This secret is automatically derived from the customer's license key and the server's master secret.
#### Using the Customer Secret
```php
use Magdev\WcLicensedProductClient\SecureLicenseClient;
use Symfony\Component\HttpClient\HttpClient;
// Customer uses their per-license secret (from account page)
$client = new SecureLicenseClient(
httpClient: HttpClient::create(),
baseUrl: 'https://shop.example.com',
serverSecret: 'customer-secret-from-account-page', // 64 hex chars
);
$info = $client->validate('XXXX-XXXX-XXXX-XXXX', 'example.com');
```
### Secret Key Rotation
To rotate the server secret:
1. Deploy new secret to server
2. Update client configurations
3. Old signatures become invalid immediately
2. All per-license secrets change automatically (they're derived)
3. Customers must copy their new secret from their account page
4. Old signatures become invalid immediately
For zero-downtime rotation, implement versioned secrets:

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

Binary file not shown.

View File

@@ -0,0 +1 @@
a489f0b8cfcd7d5d9b2021b7ff581b9f1a56468dfde87bbb06bb4555d11f7556 wc-licensed-product-0.5.1.zip

View File

@@ -94,8 +94,10 @@ final class OrderLicenseController
return;
}
// Get order domain
$orderDomain = $order->get_meta('_licensed_product_domain');
// Check for multi-domain format first, then fall back to legacy single 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
$licenses = $this->licenseManager->getLicensesByOrder($order->get_id());
@@ -104,23 +106,42 @@ final class OrderLicenseController
?>
<div class="wclp-order-licenses">
<div class="wclp-order-domain-section">
<h4><?php esc_html_e('Order Domain', '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'); ?>
</p>
<div class="wclp-inline-edit">
<input type="text"
id="wclp-order-domain"
class="regular-text"
value="<?php echo esc_attr($orderDomain); ?>"
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>
<h4><?php esc_html_e('Order Domains', 'wc-licensed-product'); ?></h4>
<?php if ($hasMultiDomain): ?>
<p class="description">
<?php esc_html_e('Domains specified during checkout (multi-domain order).', 'wc-licensed-product'); ?>
</p>
<div class="wclp-multi-domain-display" style="margin-top: 10px;">
<?php foreach ($multiDomainData as $item): ?>
<?php
$product = wc_get_product($item['product_id']);
$productName = $product ? $product->get_name() : __('Unknown Product', 'wc-licensed-product');
?>
<div class="wclp-product-domains-item" style="margin-bottom: 10px; padding: 10px; background: #f8f8f8; border-radius: 4px;">
<strong><?php echo esc_html($productName); ?>:</strong><br>
<code><?php echo esc_html(implode(', ', $item['domains'])); ?></code>
</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>
<hr />
@@ -128,15 +149,26 @@ final class OrderLicenseController
<h4><?php esc_html_e('Licenses', 'wc-licensed-product'); ?></h4>
<?php
// Count licensed products to check if all have licenses
$licensedProductCount = 0;
foreach ($order->get_items() as $item) {
$product = $item->get_product();
if ($product && $product->is_type('licensed')) {
$licensedProductCount++;
// Count expected licenses based on domain data
$expectedLicenses = 0;
if ($hasMultiDomain) {
// Multi-domain: count total domains across all products
foreach ($multiDomainData as $item) {
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)): ?>
@@ -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>
<?php endif; ?>
</p>
<?php if ($orderDomain && $order->is_paid()): ?>
<?php if ($hasDomainData && $order->is_paid()): ?>
<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()); ?>">
<?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="wclp-generate-status"></span>
</p>
<?php elseif (!$orderDomain): ?>
<?php elseif (!$hasDomainData): ?>
<p class="description" style="margin-top: 10px; color: #d63638;">
<span class="dashicons dashicons-warning"></span>
<?php esc_html_e('Please set the order domain above before generating licenses.', 'wc-licensed-product'); ?>
@@ -251,7 +283,7 @@ final class OrderLicenseController
?>
</p>
<?php if ($missingLicenses > 0 && $orderDomain && $order->is_paid()): ?>
<?php if ($missingLicenses > 0 && $hasDomainData && $order->is_paid()): ?>
<p style="margin-top: 10px;">
<span class="dashicons dashicons-warning" style="color: #dba617;"></span>
<?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')]);
}
// Get domain
$domain = $order->get_meta('_licensed_product_domain');
if (empty($domain)) {
// Check for multi-domain format first
$multiDomainData = $order->get_meta('_licensed_product_domains');
$legacyDomain = $order->get_meta('_licensed_product_domain');
if (!empty($multiDomainData) && is_array($multiDomainData)) {
// Multi-domain format
$result = $this->generateMultiDomainLicenses($order, $multiDomainData);
} elseif (!empty($legacyDomain)) {
// Legacy single domain format
$result = $this->generateLegacyLicenses($order, $legacyDomain);
} else {
wp_send_json_error(['message' => __('Please set the order domain before generating licenses.', 'wc-licensed-product')]);
return;
}
// Generate licenses for each licensed product
$generated = 0;
$skipped = 0;
foreach ($order->get_items() as $item) {
$product = $item->get_product();
if ($product && $product->is_type('licensed')) {
$license = $this->licenseManager->generateLicense(
$orderId,
$product->get_id(),
$order->get_customer_id(),
$domain
);
if ($license) {
// Check if this is a new license or existing
$existingLicenses = $this->licenseManager->getLicensesByOrder($orderId);
$isNew = true;
foreach ($existingLicenses as $existing) {
if ($existing->getProductId() === $product->get_id() && $existing->getId() !== $license->getId()) {
$isNew = false;
break;
}
}
if ($isNew) {
$generated++;
} else {
$skipped++;
}
}
}
}
if ($generated > 0) {
if ($result['generated'] > 0) {
wp_send_json_success([
'message' => sprintf(
/* translators: %d: Number of licenses generated */
_n(
'%d license generated successfully.',
'%d licenses generated successfully.',
$generated,
$result['generated'],
'wc-licensed-product'
),
$generated
$result['generated']
),
'generated' => $generated,
'skipped' => $skipped,
'generated' => $result['generated'],
'skipped' => $result['skipped'],
'reload' => true,
]);
} else {
wp_send_json_success([
'message' => __('All licenses already exist for this order.', 'wc-licensed-product'),
'generated' => 0,
'skipped' => $skipped,
'skipped' => $result['skipped'],
'reload' => false,
]);
}
}
/**
* Generate licenses for multi-domain format
*/
private function generateMultiDomainLicenses(\WC_Order $order, array $domainData): array
{
$generated = 0;
$skipped = 0;
$orderId = $order->get_id();
$customerId = $order->get_customer_id();
// Index domains by product ID
$domainsByProduct = [];
foreach ($domainData as $item) {
if (isset($item['product_id']) && isset($item['domains']) && is_array($item['domains'])) {
$domainsByProduct[(int) $item['product_id']] = $item['domains'];
}
}
foreach ($order->get_items() as $item) {
$product = $item->get_product();
if (!$product || !$product->is_type('licensed')) {
continue;
}
$productId = $product->get_id();
$domains = $domainsByProduct[$productId] ?? [];
// Get existing licenses for this product
$existingLicenses = $this->licenseManager->getLicensesByOrderAndProduct($orderId, $productId);
$existingDomains = array_map(fn($l) => $l->getDomain(), $existingLicenses);
foreach ($domains as $domain) {
$normalizedDomain = $this->licenseManager->normalizeDomain($domain);
// Skip if license already exists for this domain
if (in_array($normalizedDomain, $existingDomains, true)) {
$skipped++;
continue;
}
$license = $this->licenseManager->generateLicense(
$orderId,
$productId,
$customerId,
$normalizedDomain
);
if ($license) {
$generated++;
}
}
}
return ['generated' => $generated, 'skipped' => $skipped];
}
/**
* Generate licenses for legacy single domain format
*/
private function generateLegacyLicenses(\WC_Order $order, string $domain): array
{
$generated = 0;
$skipped = 0;
$orderId = $order->get_id();
$customerId = $order->get_customer_id();
foreach ($order->get_items() as $item) {
$product = $item->get_product();
if (!$product || !$product->is_type('licensed')) {
continue;
}
// Check if license already exists
$existing = $this->licenseManager->getLicenseByOrderAndProduct($orderId, $product->get_id());
if ($existing) {
$skipped++;
continue;
}
$license = $this->licenseManager->generateLicense(
$orderId,
$product->get_id(),
$customerId,
$domain
);
if ($license) {
$generated++;
}
}
return ['generated' => $generated, 'skipped' => $skipped];
}
}

View File

@@ -202,6 +202,13 @@ final class SettingsController
'id' => 'wc_licensed_product_default_bind_to_version',
'default' => 'no',
],
'enable_multi_domain' => [
'name' => __('Enable Multi-Domain Licensing', 'wc-licensed-product'),
'type' => 'checkbox',
'desc' => __('Allow customers to purchase multiple licenses for different domains at once. Each unit in cart quantity requires a unique domain.', 'wc-licensed-product'),
'id' => 'wc_licensed_product_enable_multi_domain',
'default' => 'no',
],
'section_end' => [
'type' => 'sectionend',
'id' => 'wc_licensed_product_section_defaults_end',
@@ -387,6 +394,14 @@ final class SettingsController
return get_option('wc_licensed_product_default_bind_to_version', 'no') === 'yes';
}
/**
* Check if multi-domain licensing is enabled
*/
public static function isMultiDomainEnabled(): bool
{
return get_option('wc_licensed_product_enable_multi_domain', 'no') === 'yes';
}
/**
* Check if expiration warning emails are enabled
* This checks both the WooCommerce email setting and the old setting for backwards compatibility

View File

@@ -147,9 +147,52 @@ final class ResponseSigner
*/
private function deriveKey(string $licenseKey): string
{
// HKDF-like key derivation
$prk = hash_hmac('sha256', $licenseKey, $this->serverSecret, true);
return self::deriveCustomerSecret($licenseKey, $this->serverSecret);
}
return hash_hmac('sha256', $prk . "\x01", $this->serverSecret);
/**
* Derive a customer-specific secret from a license key
*
* This secret is unique per license and can be shared with the customer
* to verify signed API responses. Each customer gets their own secret
* derived from their license key.
*
* @param string $licenseKey The customer's license key
* @param string $serverSecret The server's master secret
* @return string The derived secret (64 hex characters)
*/
public static function deriveCustomerSecret(string $licenseKey, string $serverSecret): string
{
// HKDF-like key derivation
$prk = hash_hmac('sha256', $licenseKey, $serverSecret, true);
return hash_hmac('sha256', $prk . "\x01", $serverSecret);
}
/**
* Get the customer secret for a license key using the configured server secret
*
* @param string $licenseKey The customer's license key
* @return string|null The derived secret, or null if server secret is not configured
*/
public static function getCustomerSecretForLicense(string $licenseKey): ?string
{
$serverSecret = defined('WC_LICENSE_SERVER_SECRET') ? WC_LICENSE_SERVER_SECRET : '';
if (empty($serverSecret)) {
return null;
}
return self::deriveCustomerSecret($licenseKey, $serverSecret);
}
/**
* Check if response signing is enabled
*
* @return bool True if server secret is configured
*/
public static function isSigningEnabled(): bool
{
return defined('WC_LICENSE_SERVER_SECRET') && !empty(WC_LICENSE_SERVER_SECRET);
}
}

View File

@@ -10,6 +10,8 @@ declare(strict_types=1);
namespace Jeremias\WcLicensedProduct\Checkout;
use Automattic\WooCommerce\Blocks\Integrations\IntegrationInterface;
use Jeremias\WcLicensedProduct\Admin\SettingsController;
use Jeremias\WcLicensedProduct\Product\LicensedProductVariation;
/**
* Integration with WooCommerce Checkout Blocks
@@ -30,7 +32,7 @@ final class CheckoutBlocksIntegration implements IntegrationInterface
public function initialize(): void
{
$this->registerScripts();
$this->registerBlockExtensionData();
$this->registerAdditionalCheckoutFields();
}
/**
@@ -45,7 +47,7 @@ final class CheckoutBlocksIntegration implements IntegrationInterface
wp_register_script(
'wc-licensed-product-checkout-blocks',
$scriptUrl,
['wc-blocks-checkout', 'wp-element', 'wp-components', 'wp-i18n'],
['wc-blocks-checkout', 'wp-element', 'wp-components', 'wp-i18n', 'wp-plugins', 'wp-data'],
WC_LICENSED_PRODUCT_VERSION,
true
);
@@ -59,20 +61,33 @@ final class CheckoutBlocksIntegration implements IntegrationInterface
}
/**
* Register block extension data
* Register additional checkout fields using WooCommerce Blocks API
*/
private function registerBlockExtensionData(): void
private function registerAdditionalCheckoutFields(): void
{
// Pass data to the checkout block script
add_filter(
'woocommerce_blocks_checkout_block_registration_data',
function (array $data): array {
$data['wc-licensed-product'] = [
'hasLicensedProducts' => $this->cartHasLicensedProducts(),
];
return $data;
add_action('woocommerce_blocks_loaded', function (): void {
// Check if the function exists (WooCommerce 8.9+)
if (!function_exists('woocommerce_register_additional_checkout_field')) {
return;
}
);
// Register the domain field using WooCommerce's checkout fields API
// For single domain mode only (multi-domain uses custom JS component)
if (!SettingsController::isMultiDomainEnabled()) {
woocommerce_register_additional_checkout_field([
'id' => 'wc-licensed-product/domain',
'label' => __('License Domain', 'wc-licensed-product'),
'location' => 'order',
'type' => 'text',
'required' => false,
'attributes' => [
'placeholder' => __('example.com', 'wc-licensed-product'),
'pattern' => '^([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}$',
'title' => __('Enter a valid domain (without http:// or www)', 'wc-licensed-product'),
],
]);
}
});
}
/**
@@ -96,13 +111,23 @@ final class CheckoutBlocksIntegration implements IntegrationInterface
*/
public function get_script_data(): array
{
$isMultiDomain = SettingsController::isMultiDomainEnabled();
return [
'hasLicensedProducts' => $this->cartHasLicensedProducts(),
'fieldLabel' => __('Domain for License Activation', 'wc-licensed-product'),
'licensedProducts' => $this->getLicensedProductsFromCart(),
'isMultiDomainEnabled' => $isMultiDomain,
'fieldPlaceholder' => __('example.com', 'wc-licensed-product'),
'fieldDescription' => __('Enter the domain where you will use this license (without http:// or www).', 'wc-licensed-product'),
'sectionTitle' => __('License Domain', 'wc-licensed-product'),
'validationError' => __('Please enter a valid domain for your license activation.', 'wc-licensed-product'),
'fieldDescription' => $isMultiDomain
? __('Enter a unique domain for each license (without http:// or www).', 'wc-licensed-product')
: __('Enter the domain where you will use the license (without http:// or www).', 'wc-licensed-product'),
'sectionTitle' => $isMultiDomain
? __('License Domains', 'wc-licensed-product')
: __('License Domain', 'wc-licensed-product'),
'validationError' => __('Please enter a valid domain.', 'wc-licensed-product'),
'duplicateError' => __('Each license requires a unique domain.', 'wc-licensed-product'),
'licenseLabel' => __('License %d:', 'wc-licensed-product'),
'singleDomainLabel' => __('Domain', 'wc-licensed-product'),
];
}
@@ -110,18 +135,70 @@ final class CheckoutBlocksIntegration implements IntegrationInterface
* Check if cart contains licensed products
*/
private function cartHasLicensedProducts(): bool
{
return !empty($this->getLicensedProductsFromCart());
}
/**
* Get licensed products from cart with quantities
*
* @return array<string, array{product_id: int, variation_id: int, name: string, quantity: int, duration_label: string}>
*/
private function getLicensedProductsFromCart(): array
{
if (!WC()->cart) {
return false;
return [];
}
$licensedProducts = [];
foreach (WC()->cart->get_cart() as $cartItem) {
$product = $cartItem['data'];
if ($product && $product->is_type('licensed')) {
return true;
if (!$product) {
continue;
}
// Check for simple licensed products
if ($product->is_type('licensed')) {
$productId = $product->get_id();
$licensedProducts[] = [
'product_id' => $productId,
'variation_id' => 0,
'name' => $product->get_name(),
'quantity' => (int) $cartItem['quantity'],
'duration_label' => '',
];
continue;
}
// Check for variations of licensed-variable products
if ($product->is_type('variation')) {
$parentId = $product->get_parent_id();
$parent = wc_get_product($parentId);
if ($parent && $parent->is_type('licensed-variable')) {
$variationId = $product->get_id();
// Get duration label if it's a LicensedProductVariation
$durationLabel = '';
if ($product instanceof LicensedProductVariation) {
$durationLabel = $product->get_license_duration_label();
} else {
// Try to instantiate as LicensedProductVariation
$variation = new LicensedProductVariation($variationId);
$durationLabel = $variation->get_license_duration_label();
}
$licensedProducts[] = [
'product_id' => $parentId,
'variation_id' => $variationId,
'name' => $product->get_name(),
'quantity' => (int) $cartItem['quantity'],
'duration_label' => $durationLabel,
];
}
}
}
return false;
return $licensedProducts;
}
}

View File

@@ -10,6 +10,8 @@ declare(strict_types=1);
namespace Jeremias\WcLicensedProduct\Checkout;
use Jeremias\WcLicensedProduct\License\LicenseManager;
use Jeremias\WcLicensedProduct\Admin\SettingsController;
use Jeremias\WcLicensedProduct\Product\LicensedProductVariation;
/**
* Handles checkout modifications for licensed products
@@ -50,35 +52,113 @@ final class CheckoutController
*/
private function cartHasLicensedProducts(): bool
{
if (!WC()->cart) {
return false;
}
foreach (WC()->cart->get_cart() as $cartItem) {
$product = $cartItem['data'];
if ($product && $product->is_type('licensed')) {
return true;
}
}
return false;
return !empty($this->getLicensedProductsFromCart());
}
/**
* Add domain field to checkout form
* Get licensed products from cart with quantities
*
* @return array<string, array{product_id: int, variation_id: int, name: string, quantity: int, duration_label: string}>
*/
private function getLicensedProductsFromCart(): array
{
if (!WC()->cart) {
return [];
}
$licensedProducts = [];
foreach (WC()->cart->get_cart() as $cartItem) {
$product = $cartItem['data'];
if (!$product) {
continue;
}
// Check for simple licensed products
if ($product->is_type('licensed')) {
$productId = $product->get_id();
$licensedProducts[$productId] = [
'product_id' => $productId,
'variation_id' => 0,
'name' => $product->get_name(),
'quantity' => (int) $cartItem['quantity'],
'duration_label' => '',
];
continue;
}
// Check for variations of licensed-variable products
if ($product->is_type('variation')) {
$parentId = $product->get_parent_id();
$parent = wc_get_product($parentId);
if ($parent && $parent->is_type('licensed-variable')) {
$variationId = $product->get_id();
// Use combination key to allow same product with different variations
$key = "{$parentId}_{$variationId}";
// Get duration label if it's a LicensedProductVariation
$durationLabel = '';
if ($product instanceof LicensedProductVariation) {
$durationLabel = $product->get_license_duration_label();
} else {
// Try to instantiate as LicensedProductVariation
$variation = new LicensedProductVariation($variationId);
$durationLabel = $variation->get_license_duration_label();
}
$licensedProducts[$key] = [
'product_id' => $parentId,
'variation_id' => $variationId,
'name' => $product->get_name(),
'quantity' => (int) $cartItem['quantity'],
'duration_label' => $durationLabel,
];
}
}
}
return $licensedProducts;
}
/**
* Add domain fields to checkout form
* Shows multiple domain fields if multi-domain is enabled, otherwise single field
*/
public function addDomainField(): void
{
if (!$this->cartHasLicensedProducts()) {
$licensedProducts = $this->getLicensedProductsFromCart();
if (empty($licensedProducts)) {
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">
<h3><?php esc_html_e('License Domain', 'wc-licensed-product'); ?></h3>
<p class="form-row form-row-wide">
<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>
</label>
<input
@@ -87,10 +167,10 @@ final class CheckoutController
name="licensed_product_domain"
id="licensed_product_domain"
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">
<?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>
</p>
</div>
@@ -98,62 +178,318 @@ 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 $key => $productData): ?>
<?php
$productId = $productData['product_id'];
$variationId = $productData['variation_id'] ?? 0;
$durationLabel = $productData['duration_label'] ?? '';
// Use key for field names to handle variations
$fieldKey = $variationId > 0 ? "{$productId}_{$variationId}" : $productId;
?>
<div class="wclp-product-domains" data-product-id="<?php echo esc_attr($productId); ?>" data-variation-id="<?php echo esc_attr($variationId); ?>">
<h4>
<?php
echo esc_html($productData['name']);
if (!empty($durationLabel)) {
echo ' <span class="wclp-duration-badge">(' . esc_html($durationLabel) . ')</span>';
}
if ($productData['quantity'] > 1) {
printf(' ×%d', $productData['quantity']);
}
?>
</h4>
<?php for ($i = 0; $i < $productData['quantity']; $i++): ?>
<?php
$fieldName = sprintf('licensed_domains[%s][%d]', $fieldKey, $i);
$fieldId = sprintf('licensed_domain_%s_%d', str_replace('_', '-', $fieldKey), $i);
$savedValue = $this->getSavedDomainValue($productId, $i, $variationId);
?>
<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); ?>"
/>
<?php if ($variationId > 0): ?>
<input type="hidden" name="licensed_variation_ids[<?php echo esc_attr($fieldKey); ?>]" value="<?php echo esc_attr($variationId); ?>" />
<?php endif; ?>
</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-duration-badge { color: #0073aa; font-weight: normal; }
.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, int $variationId = 0): string
{
// Build the field key (with or without variation)
$fieldKey = $variationId > 0 ? "{$productId}_{$variationId}" : (string) $productId;
// Check POST data first (validation failure case)
if (isset($_POST['licensed_domains'][$fieldKey][$index])) {
return sanitize_text_field($_POST['licensed_domains'][$fieldKey][$index]);
}
// Also try numeric key for backward compatibility
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) {
$itemProductId = (int) ($item['product_id'] ?? 0);
$itemVariationId = (int) ($item['variation_id'] ?? 0);
// Match by product and variation
if ($itemProductId === $productId && $itemVariationId === $variationId) {
if (isset($item['domains'][$index])) {
return $item['domains'][$index];
}
}
}
}
return '';
}
/**
* Validate domain fields during checkout
*/
public function validateDomainField(): void
{
if (!$this->cartHasLicensedProducts()) {
$licensedProducts = $this->getLicensedProductsFromCart();
if (empty($licensedProducts)) {
return;
}
$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 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'
);
// Check if multi-domain licensing is enabled
if (SettingsController::isMultiDomainEnabled()) {
$this->validateMultiDomainFields($licensedProducts);
} else {
$this->validateSingleDomainField();
}
}
/**
* 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;
}
if (isset($_POST['licensed_product_domain']) && !empty($_POST['licensed_product_domain'])) {
$domain = sanitize_text_field($_POST['licensed_product_domain']);
$normalizedDomain = $this->licenseManager->normalizeDomain($domain);
$normalizedDomain = $this->licenseManager->normalizeDomain($domain);
if (!$this->isValidDomain($normalizedDomain)) {
wc_add_notice(__('Please enter a valid domain for your license.', 'wc-licensed-product'), 'error');
}
}
$order = wc_get_order($orderId);
if ($order) {
$order->update_meta_data('_licensed_product_domain', $normalizedDomain);
$order->save();
/**
* Validate multi-domain fields
*/
private function validateMultiDomainFields(array $licensedProducts): void
{
$licensedDomains = $_POST['licensed_domains'] ?? [];
foreach ($licensedProducts as $key => $productData) {
$productId = $productData['product_id'];
$variationId = $productData['variation_id'] ?? 0;
$fieldKey = $variationId > 0 ? "{$productId}_{$variationId}" : (string) $productId;
$productDomains = $licensedDomains[$fieldKey] ?? $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/variation
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'] ?? [];
$licensedVariationIds = $_POST['licensed_variation_ids'] ?? [];
$domainData = [];
foreach ($licensedProducts as $key => $productData) {
$productId = $productData['product_id'];
$variationId = $productData['variation_id'] ?? 0;
$fieldKey = $variationId > 0 ? "{$productId}_{$variationId}" : (string) $productId;
$productDomains = $licensedDomains[$fieldKey] ?? $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)) {
$entry = [
'product_id' => $productId,
'domains' => $normalizedDomains,
];
// Include variation_id if present
if ($variationId > 0) {
$entry['variation_id'] = $variationId;
}
$domainData[] = $entry;
}
}
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
{
// 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');
if (!$domain) {
return;
@@ -168,10 +504,54 @@ 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
$productId = $item['product_id'];
$variationId = $item['variation_id'] ?? 0;
// Get product name
if ($variationId > 0) {
$variation = wc_get_product($variationId);
$productName = $variation ? $variation->get_name() : __('Unknown Variation', 'wc-licensed-product');
// Add duration label if available
if ($variation instanceof LicensedProductVariation) {
$productName .= ' (' . $variation->get_license_duration_label() . ')';
}
} else {
$product = wc_get_product($productId);
$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
{
// 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');
if (!$domain) {
return;
@@ -189,6 +569,60 @@ 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) {
$productId = $item['product_id'];
$variationId = $item['variation_id'] ?? 0;
if ($variationId > 0) {
$variation = wc_get_product($variationId);
$productName = $variation ? $variation->get_name() : __('Unknown Variation', 'wc-licensed-product');
if ($variation instanceof LicensedProductVariation) {
$productName .= ' (' . $variation->get_license_duration_label() . ')';
}
} else {
$product = wc_get_product($productId);
$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
$productId = $item['product_id'];
$variationId = $item['variation_id'] ?? 0;
if ($variationId > 0) {
$variation = wc_get_product($variationId);
$productName = $variation ? $variation->get_name() : __('Unknown Variation', 'wc-licensed-product');
if ($variation instanceof LicensedProductVariation) {
$productName .= ' (' . $variation->get_license_duration_label() . ')';
}
} else {
$product = wc_get_product($productId);
$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
*/

View File

@@ -12,6 +12,7 @@ namespace Jeremias\WcLicensedProduct\Checkout;
use Automattic\WooCommerce\StoreApi\Schemas\V1\CheckoutSchema;
use Automattic\WooCommerce\StoreApi\StoreApi;
use Automattic\WooCommerce\StoreApi\Schemas\ExtendSchema;
use Jeremias\WcLicensedProduct\Admin\SettingsController;
use Jeremias\WcLicensedProduct\License\LicenseManager;
/**
@@ -70,6 +71,12 @@ final class StoreApiExtension
*/
public function getExtensionData(): array
{
if (SettingsController::isMultiDomainEnabled()) {
return [
'licensed_product_domains' => WC()->session ? WC()->session->get('licensed_product_domains', []) : [],
];
}
return [
'licensed_product_domain' => WC()->session ? WC()->session->get('licensed_product_domain', '') : '',
];
@@ -80,6 +87,34 @@ final class StoreApiExtension
*/
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',
],
'variation_id' => [
'type' => 'integer',
],
'domains' => [
'type' => 'array',
'items' => [
'type' => 'string',
],
],
],
],
],
];
}
return [
'licensed_product_domain' => [
'description' => __('Domain for license activation', 'wc-licensed-product'),
@@ -95,30 +130,111 @@ final class StoreApiExtension
*/
public function handleExtensionUpdate(array $data): void
{
if (isset($data['licensed_product_domain'])) {
$domain = sanitize_text_field($data['licensed_product_domain']);
$normalizedDomain = $this->licenseManager->normalizeDomain($domain);
if (SettingsController::isMultiDomainEnabled()) {
// Multi-domain mode
if (isset($data['licensed_product_domains']) && is_array($data['licensed_product_domains'])) {
$normalizedData = $this->normalizeDomainsData($data['licensed_product_domains']);
if (WC()->session) {
WC()->session->set('licensed_product_domain', $normalizedDomain);
if (WC()->session) {
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'];
$variationId = isset($item['variation_id']) ? (int) $item['variation_id'] : 0;
$domains = [];
foreach ($item['domains'] as $domain) {
$sanitized = sanitize_text_field($domain);
if (!empty($sanitized)) {
$domains[] = $this->licenseManager->normalizeDomain($sanitized);
}
}
if (!empty($domains)) {
$entry = [
'product_id' => $productId,
'domains' => $domains,
];
// Include variation_id if present
if ($variationId > 0) {
$entry['variation_id'] = $variationId;
}
$normalized[] = $entry;
}
}
return $normalized;
}
/**
* Process the checkout order - save domains to order meta
*/
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 (empty($domain)) {
$requestData = json_decode(file_get_contents('php://input'), true);
if (isset($requestData['extensions'][self::IDENTIFIER]['licensed_product_domain'])) {
$domain = sanitize_text_field($requestData['extensions'][self::IDENTIFIER]['licensed_product_domain']);
$domain = $this->licenseManager->normalizeDomain($domain);
}
if (SettingsController::isMultiDomainEnabled()) {
$this->processMultiDomainOrder($order, $requestData);
} else {
$this->processSingleDomainOrder($order, $requestData);
}
}
/**
* 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)) {
@@ -131,4 +247,84 @@ 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 = [];
$variationIds = $requestData['licensed_variation_ids'] ?? [];
foreach ($requestData['licensed_domains'] as $key => $domains) {
if (!is_array($domains)) {
continue;
}
// Parse key - could be "productId" or "productId_variationId"
$parts = explode('_', (string) $key);
$productId = (int) $parts[0];
$variationId = isset($parts[1]) ? (int) $parts[1] : 0;
// Also check for hidden variation ID field
if ($variationId === 0 && isset($variationIds[$key])) {
$variationId = (int) $variationIds[$key];
}
$normalizedDomains = [];
foreach ($domains as $domain) {
$sanitized = sanitize_text_field($domain);
if (!empty($sanitized)) {
$normalizedDomains[] = $this->licenseManager->normalizeDomain($sanitized);
}
}
if (!empty($normalizedDomains)) {
$entry = [
'product_id' => $productId,
'domains' => $normalizedDomains,
];
if ($variationId > 0) {
$entry['variation_id'] = $variationId;
}
$domainData[] = $entry;
}
}
}
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
{
@@ -203,94 +203,117 @@ final class LicenseEmailController
return;
}
$license = $this->licenseManager->getLicenseByOrderAndProduct($order->get_id(), $product->get_id());
if (!$license) {
$licenses = $this->licenseManager->getLicensesByOrderAndProduct($order->get_id(), $product->get_id());
if (empty($licenses)) {
return;
}
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 {
?>
<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>
<code style="display: block; margin-top: 5px; padding: 5px; background: #fff; font-family: monospace;">
<?php echo esc_html($license->getLicenseKey()); ?>
</code>
<strong><?php esc_html_e('License Keys:', 'wc-licensed-product'); ?></strong>
<?php foreach ($licenses as $license) : ?>
<div style="margin-top: 5px; padding: 5px; background: #fff;">
<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>
<?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
{
$licenses = [];
$products = [];
foreach ($order->get_items() as $item) {
$product = $item->get_product();
if ($product && $product->is_type('licensed')) {
$license = $this->licenseManager->getLicenseByOrderAndProduct($order->get_id(), $product->get_id());
if ($license) {
$licenses[] = [
'license' => $license,
$licenses = $this->licenseManager->getLicensesByOrderAndProduct($order->get_id(), $product->get_id());
if (!empty($licenses)) {
$products[] = [
'product_name' => $product->get_name(),
'licenses' => $licenses,
];
}
}
}
return $licenses;
return $products;
}
/**
* 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;">
<h2 style="margin-top: 0; color: #333;"><?php esc_html_e('Your License Keys', 'wc-licensed-product'); ?></h2>
<?php if ($domain) : ?>
<p style="margin-bottom: 15px;">
<strong><?php esc_html_e('Licensed Domain:', 'wc-licensed-product'); ?></strong>
<?php echo esc_html($domain); ?>
</p>
<?php endif; ?>
<?php foreach ($products as $product) : ?>
<div style="margin-bottom: 20px;">
<h3 style="margin: 0 0 10px 0; font-size: 1.1em; color: #333;">
<?php echo esc_html($product['product_name']); ?>
<span style="font-weight: normal; color: #666; font-size: 0.9em;">
(<?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;">
<thead>
<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: 10px; border-bottom: 2px solid #ddd;"><?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('Expires', 'wc-licensed-product'); ?></th>
</tr>
</thead>
<tbody>
<?php foreach ($licenses as $item) : ?>
<tr>
<td style="padding: 10px; border-bottom: 1px solid #eee;"><?php echo esc_html($item['product_name']); ?></td>
<td style="padding: 10px; border-bottom: 1px solid #eee;">
<code style="background: #fff; padding: 3px 6px; font-family: monospace;">
<?php echo esc_html($item['license']->getLicenseKey()); ?>
</code>
</td>
<td style="padding: 10px; border-bottom: 1px solid #eee;">
<?php
$expiresAt = $item['license']->getExpiresAt();
echo $expiresAt
? esc_html($expiresAt->format(get_option('date_format')))
: esc_html__('Never', 'wc-licensed-product');
?>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<table style="width: 100%; border-collapse: collapse; background: #fff;">
<thead>
<tr>
<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: 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: 8px 10px; border-bottom: 2px solid #ddd; font-size: 0.9em;"><?php esc_html_e('Expires', 'wc-licensed-product'); ?></th>
</tr>
</thead>
<tbody>
<?php foreach ($product['licenses'] as $license) : ?>
<tr>
<td style="padding: 8px 10px; border-bottom: 1px solid #eee;">
<code style="background: #f5f5f5; padding: 3px 6px; font-family: monospace; font-size: 0.9em;">
<?php echo esc_html($license->getLicenseKey()); ?>
</code>
</td>
<td style="padding: 8px 10px; border-bottom: 1px solid #eee;">
<?php echo esc_html($license->getDomain()); ?>
</td>
<td style="padding: 8px 10px; border-bottom: 1px solid #eee;">
<?php
$expiresAt = $license->getExpiresAt();
echo $expiresAt
? esc_html($expiresAt->format(get_option('date_format')))
: esc_html__('Never', 'wc-licensed-product');
?>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<?php endforeach; ?>
<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'); ?>
@@ -302,29 +325,33 @@ final class LicenseEmailController
/**
* Render license info in plain text format
*/
private function renderPlainTextLicenseInfo(array $licenses, \WC_Order $order): void
private function renderPlainTextLicenseInfo(array $products, \WC_Order $order): void
{
$domain = $order->get_meta('_licensed_product_domain');
echo "\n\n";
echo "==========================================================\n";
echo esc_html__('YOUR LICENSE KEYS', 'wc-licensed-product') . "\n";
echo "==========================================================\n\n";
if ($domain) {
echo esc_html__('Licensed Domain:', 'wc-licensed-product') . ' ' . esc_html($domain) . "\n\n";
}
foreach ($products as $product) {
echo esc_html($product['product_name']);
echo ' (' . count($product['licenses']) . ' ' .
_n('license', 'licenses', count($product['licenses']), 'wc-licensed-product') . ')';
echo "\n";
echo "-----------------------------------------------------------\n";
foreach ($licenses as $item) {
echo esc_html($item['product_name']) . "\n";
echo esc_html__('License Key:', 'wc-licensed-product') . ' ' . esc_html($item['license']->getLicenseKey()) . "\n";
foreach ($product['licenses'] as $license) {
echo esc_html__('License Key:', 'wc-licensed-product') . ' ';
echo esc_html($license->getLicenseKey()) . "\n";
echo esc_html__('Domain:', 'wc-licensed-product') . ' ';
echo esc_html($license->getDomain()) . "\n";
echo esc_html__('Expires:', 'wc-licensed-product') . ' ';
$expiresAt = $item['license']->getExpiresAt();
echo esc_html__('Expires:', 'wc-licensed-product') . ' ';
echo $expiresAt
? esc_html($expiresAt->format(get_option('date_format')))
: esc_html__('Never', 'wc-licensed-product');
echo "\n\n";
$expiresAt = $license->getExpiresAt();
echo $expiresAt
? esc_html($expiresAt->format(get_option('date_format')))
: esc_html__('Never', 'wc-licensed-product');
echo "\n\n";
}
}
echo esc_html__('You can also view your licenses in your account under "Licenses".', 'wc-licensed-product') . "\n";

View File

@@ -9,6 +9,7 @@ declare(strict_types=1);
namespace Jeremias\WcLicensedProduct\Frontend;
use Jeremias\WcLicensedProduct\Api\ResponseSigner;
use Jeremias\WcLicensedProduct\License\LicenseManager;
use Jeremias\WcLicensedProduct\Product\VersionManager;
use Twig\Environment;
@@ -107,135 +108,250 @@ final class AccountController
$licenses = $this->licenseManager->getLicensesByCustomer($customerId);
// Enrich licenses with product data and downloads
$enrichedLicenses = [];
foreach ($licenses as $license) {
$product = wc_get_product($license->getProductId());
$order = wc_get_order($license->getOrderId());
// Get available downloads for this license
$downloads = [];
if ($license->getStatus() === 'active') {
$versions = $this->versionManager->getVersionsByProduct($license->getProductId());
foreach ($versions as $version) {
if ($version->isActive() && ($version->getAttachmentId() || $version->getDownloadUrl())) {
$downloads[] = [
'version' => $version->getVersion(),
'version_id' => $version->getId(),
'filename' => $version->getDownloadFilename(),
'download_url' => $this->downloadController->generateDownloadUrl(
$license->getId(),
$version->getId()
),
'release_notes' => $version->getReleaseNotes(),
'released_at' => $version->getReleasedAt()->format(get_option('date_format')),
'file_hash' => $version->getFileHash(),
];
}
}
}
$enrichedLicenses[] = [
'license' => $license,
'product_name' => $product ? $product->get_name() : __('Unknown Product', 'wc-licensed-product'),
'product_url' => $product ? $product->get_permalink() : '',
'order_number' => $order ? $order->get_order_number() : '',
'order_url' => $order ? $order->get_view_order_url() : '',
'downloads' => $downloads,
];
}
// Group licenses by product+order into "packages"
$packages = $this->groupLicensesIntoPackages($licenses);
try {
echo $this->twig->render('frontend/licenses.html.twig', [
'licenses' => $enrichedLicenses,
'has_licenses' => !empty($enrichedLicenses),
'packages' => $packages,
'has_packages' => !empty($packages),
'signing_enabled' => ResponseSigner::isSigningEnabled(),
]);
} catch (\Exception $e) {
// Fallback to PHP template if Twig fails
$this->displayLicensesFallback($enrichedLicenses);
$this->displayLicensesFallback($packages);
}
}
/**
* Group licenses into packages by product+order
*
* @param array $licenses Array of License objects
* @return array Array of package data
*/
private function groupLicensesIntoPackages(array $licenses): array
{
$grouped = [];
foreach ($licenses as $license) {
$productId = $license->getProductId();
$orderId = $license->getOrderId();
$key = $productId . '_' . $orderId;
if (!isset($grouped[$key])) {
$product = wc_get_product($productId);
$order = wc_get_order($orderId);
$grouped[$key] = [
'product_id' => $productId,
'order_id' => $orderId,
'product_name' => $product ? $product->get_name() : __('Unknown Product', 'wc-licensed-product'),
'product_url' => $product ? $product->get_permalink() : '',
'order_number' => $order ? $order->get_order_number() : '',
'order_url' => $order ? $order->get_view_order_url() : '',
'licenses' => [],
'downloads' => [],
'has_active_license' => false,
];
}
// Add license to package
$grouped[$key]['licenses'][] = [
'id' => $license->getId(),
'license_key' => $license->getLicenseKey(),
'domain' => $license->getDomain(),
'status' => $license->getStatus(),
'expires_at' => $license->getExpiresAt(),
'is_transferable' => in_array($license->getStatus(), ['active', 'inactive'], true),
'customer_secret' => ResponseSigner::getCustomerSecretForLicense($license->getLicenseKey()),
];
// Track if package has at least one active license
if ($license->getStatus() === 'active') {
$grouped[$key]['has_active_license'] = true;
}
}
// Add downloads for packages with active licenses
foreach ($grouped as $key => &$package) {
if ($package['has_active_license']) {
$package['downloads'] = $this->getDownloadsForProduct(
$package['product_id'],
$package['licenses'][0]['id'] // Use first license for download URL
);
}
}
// Sort by order date (newest first) - re-index array
return array_values($grouped);
}
/**
* Get downloads for a product
*/
private function getDownloadsForProduct(int $productId, int $licenseId): array
{
$downloads = [];
$versions = $this->versionManager->getVersionsByProduct($productId);
foreach ($versions as $version) {
if ($version->isActive() && ($version->getAttachmentId() || $version->getDownloadUrl())) {
$downloads[] = [
'version' => $version->getVersion(),
'version_id' => $version->getId(),
'filename' => $version->getDownloadFilename(),
'download_url' => $this->downloadController->generateDownloadUrl(
$licenseId,
$version->getId()
),
'release_notes' => $version->getReleaseNotes(),
'released_at' => $version->getReleasedAt()->format(get_option('date_format')),
'file_hash' => $version->getFileHash(),
];
}
}
return $downloads;
}
/**
* Fallback display method if Twig is unavailable
*/
private function displayLicensesFallback(array $enrichedLicenses): void
private function displayLicensesFallback(array $packages): void
{
if (empty($enrichedLicenses)) {
if (empty($packages)) {
echo '<p>' . esc_html__('You have no licenses yet.', 'wc-licensed-product') . '</p>';
return;
}
?>
<div class="woocommerce-licenses">
<?php foreach ($enrichedLicenses as $item): ?>
<div class="license-card">
<div class="license-header">
<?php foreach ($packages as $package): ?>
<div class="license-package">
<div class="package-header">
<h3>
<?php if ($item['product_url']): ?>
<a href="<?php echo esc_url($item['product_url']); ?>">
<?php echo esc_html($item['product_name']); ?>
<?php if ($package['product_url']): ?>
<a href="<?php echo esc_url($package['product_url']); ?>">
<?php echo esc_html($package['product_name']); ?>
</a>
<?php else: ?>
<?php echo esc_html($item['product_name']); ?>
<?php echo esc_html($package['product_name']); ?>
<?php endif; ?>
</h3>
<span class="license-status license-status-<?php echo esc_attr($item['license']->getStatus()); ?>">
<?php echo esc_html(ucfirst($item['license']->getStatus())); ?>
<span class="package-order">
<?php
printf(
/* translators: %s: order number */
esc_html__('Order #%s', 'wc-licensed-product'),
esc_html($package['order_number'])
);
?>
</span>
</div>
<div class="license-details">
<div class="license-key-row">
<label><?php esc_html_e('License Key:', 'wc-licensed-product'); ?></label>
<code class="license-key" data-license-key="<?php echo esc_attr($item['license']->getLicenseKey()); ?>">
<?php echo esc_html($item['license']->getLicenseKey()); ?>
</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="dashicons dashicons-clipboard"></span>
</button>
</div>
<div class="license-info-row">
<span class="license-domain-display" data-license-id="<?php echo esc_attr($item['license']->getId()); ?>">
<strong><?php esc_html_e('Domain:', 'wc-licensed-product'); ?></strong>
<span class="domain-value"><?php echo esc_html($item['license']->getDomain()); ?></span>
<?php if (in_array($item['license']->getStatus(), ['active', 'inactive'], true)): ?>
<button type="button" class="wclp-transfer-btn"
data-license-id="<?php echo esc_attr($item['license']->getId()); ?>"
data-current-domain="<?php echo esc_attr($item['license']->getDomain()); ?>"
title="<?php esc_attr_e('Transfer to new domain', 'wc-licensed-product'); ?>">
<span class="dashicons dashicons-randomize"></span>
<?php esc_html_e('Transfer', 'wc-licensed-product'); ?>
</button>
<?php endif; ?>
</span>
<span><strong><?php esc_html_e('Expires:', 'wc-licensed-product'); ?></strong>
<?php
$expiresAt = $item['license']->getExpiresAt();
echo $expiresAt
? esc_html($expiresAt->format(get_option('date_format')))
: esc_html__('Never', 'wc-licensed-product');
?>
</span>
</div>
<div class="package-licenses">
<?php foreach ($package['licenses'] as $license): ?>
<div class="license-entry license-entry-<?php echo esc_attr($license['status']); ?>">
<div class="license-row-primary">
<div class="license-key-group">
<code class="license-key"><?php echo esc_html($license['license_key']); ?></code>
<span class="license-status license-status-<?php echo esc_attr($license['status']); ?>">
<?php echo esc_html(ucfirst($license['status'])); ?>
</span>
</div>
<div class="license-actions">
<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="dashicons dashicons-clipboard"></span>
</button>
<?php if ($license['is_transferable']): ?>
<button type="button" class="wclp-transfer-btn"
data-license-id="<?php echo esc_attr($license['id']); ?>"
data-current-domain="<?php echo esc_attr($license['domain']); ?>"
title="<?php esc_attr_e('Transfer to new domain', 'wc-licensed-product'); ?>">
<span class="dashicons dashicons-randomize"></span>
</button>
<?php endif; ?>
</div>
</div>
<div class="license-row-secondary">
<span class="license-meta-item license-domain">
<span class="dashicons dashicons-admin-site-alt3"></span>
<?php echo esc_html($license['domain']); ?>
</span>
<span class="license-meta-item license-expiry">
<span class="dashicons dashicons-calendar-alt"></span>
<?php
echo $license['expires_at']
? 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>
<?php if (!empty($item['downloads'])): ?>
<div class="license-downloads">
<?php if (!empty($package['downloads'])): ?>
<div class="package-downloads">
<h4><?php esc_html_e('Available Downloads', 'wc-licensed-product'); ?></h4>
<ul class="download-list">
<?php foreach ($item['downloads'] as $download): ?>
<li>
<a href="<?php echo esc_url($download['download_url']); ?>" class="download-link">
<?php
$latest = $package['downloads'][0];
?>
<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>
<?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>
<span class="download-version">v<?php echo esc_html($download['version']); ?></span>
<span class="download-date"><?php echo esc_html($download['released_at']); ?></span>
</li>
<?php endforeach; ?>
<span class="download-version-badge"><?php esc_html_e('Latest', 'wc-licensed-product'); ?></span>
</div>
<div class="download-row-meta">
<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>
<?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>
<?php endif; ?>
</div>

View File

@@ -11,12 +11,43 @@ namespace Jeremias\WcLicensedProduct\License;
use Jeremias\WcLicensedProduct\Installer;
use Jeremias\WcLicensedProduct\Product\LicensedProduct;
use Jeremias\WcLicensedProduct\Product\LicensedProductVariation;
use Jeremias\WcLicensedProduct\Product\LicensedVariableProduct;
/**
* Manages license operations (CRUD, validation, generation)
*/
class LicenseManager
{
/**
* Check if a product is any type of licensed product
*
* @param \WC_Product $product Product to check
* @return bool True if product is licensed (simple or variable or variation)
*/
public function isLicensedProduct(\WC_Product $product): bool
{
// Simple licensed product
if ($product->is_type('licensed')) {
return true;
}
// Variable licensed product
if ($product->is_type('licensed-variable')) {
return true;
}
// Variation of a licensed-variable product
if ($product->is_type('variation') && $product->get_parent_id()) {
$parent = wc_get_product($product->get_parent_id());
if ($parent && $parent->is_type('licensed-variable')) {
return true;
}
}
return false;
}
/**
* Generate a unique license key
*/
@@ -40,29 +71,63 @@ class LicenseManager
/**
* Generate a license for a completed order
*
* @param int $orderId Order ID
* @param int $productId Product ID (parent product for variations)
* @param int $customerId Customer ID
* @param string $domain Domain to bind the license to
* @param int|null $variationId Optional variation ID for variable licensed products
* @return License|null Generated license or null on failure
*/
public function generateLicense(
int $orderId,
int $productId,
int $customerId,
string $domain
string $domain,
?int $variationId = null
): ?License {
global $wpdb;
// Check if license already exists for this order and product
$existing = $this->getLicenseByOrderAndProduct($orderId, $productId);
// Normalize domain first for duplicate detection
$normalizedDomain = $this->normalizeDomain($domain);
// Check if license already exists for this order, product, domain, and variation
$existing = $this->getLicenseByOrderProductDomainAndVariation($orderId, $productId, $normalizedDomain, $variationId);
if ($existing) {
return $existing;
}
$product = wc_get_product($productId);
if (!$product || !$product->is_type('licensed')) {
return null;
// Load the product that has the license settings
// For variations, load the variation; otherwise load the parent product
if ($variationId) {
$settingsProduct = wc_get_product($variationId);
$parentProduct = wc_get_product($productId);
// Verify parent is licensed-variable
if (!$parentProduct || !$parentProduct->is_type('licensed-variable')) {
return null;
}
// Ensure we have the proper variation class
if ($settingsProduct && !$settingsProduct instanceof LicensedProductVariation) {
$settingsProduct = new LicensedProductVariation($variationId);
}
} else {
$settingsProduct = wc_get_product($productId);
// Check if this is a licensed product (simple)
if (!$settingsProduct || !$settingsProduct->is_type('licensed')) {
return null;
}
// Ensure we have the LicensedProduct instance for type hints
if (!$settingsProduct instanceof LicensedProduct) {
$settingsProduct = new LicensedProduct($productId);
}
}
// Ensure we have the LicensedProduct instance for type hints
if (!$product instanceof LicensedProduct) {
$product = new LicensedProduct($productId);
if (!$settingsProduct) {
return null;
}
// Generate unique license key
@@ -71,16 +136,16 @@ class LicenseManager
$licenseKey = $this->generateLicenseKey();
}
// Calculate expiration date
// Calculate expiration date from the settings product (variation or parent)
$expiresAt = null;
$validityDays = $product->get_validity_days();
$validityDays = $settingsProduct->get_validity_days();
if ($validityDays !== null && $validityDays > 0) {
$expiresAt = (new \DateTimeImmutable())->modify("+{$validityDays} days")->format('Y-m-d H:i:s');
}
// Determine version ID if bound to version
// Determine version ID if bound to version (always use parent product ID for versions)
$versionId = null;
if ($product->is_bound_to_version()) {
if ($settingsProduct->is_bound_to_version()) {
$versionId = $this->getCurrentVersionId($productId);
}
@@ -96,7 +161,7 @@ class LicenseManager
'version_id' => $versionId,
'status' => License::STATUS_ACTIVE,
'activations_count' => 1,
'max_activations' => $product->get_max_activations(),
'max_activations' => $settingsProduct->get_max_activations(),
'expires_at' => $expiresAt,
],
['%s', '%d', '%d', '%d', '%s', '%d', '%s', '%d', '%d', '%s']
@@ -109,6 +174,16 @@ class LicenseManager
return $this->getLicenseById((int) $wpdb->insert_id);
}
/**
* Get license by order, product, domain, and optional variation
*/
public function getLicenseByOrderProductDomainAndVariation(int $orderId, int $productId, string $domain, ?int $variationId = null): ?License
{
// For now, just use the existing method since we don't store variation_id in licenses table yet
// In the future, we could add a variation_id column to the licenses table
return $this->getLicenseByOrderProductAndDomain($orderId, $productId, $domain);
}
/**
* Get license by ID
*/
@@ -161,6 +236,49 @@ class LicenseManager
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
*/

View File

@@ -52,6 +52,11 @@ final class PluginLicenseChecker
*/
private ?bool $isLocalhostCached = null;
/**
* Cached self-licensing check result
*/
private ?bool $isSelfLicensingCached = null;
/**
* Get singleton instance
*/
@@ -84,6 +89,11 @@ final class PluginLicenseChecker
return true;
}
// Always valid when self-licensing (server URL points to this installation)
if ($this->isSelfLicensing()) {
return true;
}
// Check cache first
$cached = get_transient(self::CACHE_KEY);
if ($cached !== false) {
@@ -107,6 +117,11 @@ final class PluginLicenseChecker
return true;
}
// Always valid when self-licensing (server URL points to this installation)
if ($this->isSelfLicensing()) {
return true;
}
// Check settings are configured
$serverUrl = $this->getLicenseServerUrl();
$licenseKey = $this->getLicenseKey();
@@ -176,6 +191,7 @@ final class PluginLicenseChecker
delete_transient(self::CACHE_KEY);
delete_transient(self::ERROR_CACHE_KEY);
$this->isLocalhostCached = null;
$this->isSelfLicensingCached = null;
}
/**
@@ -215,6 +231,60 @@ final class PluginLicenseChecker
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
*/

View File

@@ -208,22 +208,98 @@ final class Plugin
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 (and variation ID for variable products)
$domainsByProduct = [];
foreach ($domainData as $item) {
if (isset($item['product_id']) && isset($item['domains']) && is_array($item['domains'])) {
$productId = (int) $item['product_id'];
$variationId = isset($item['variation_id']) ? (int) $item['variation_id'] : 0;
$key = $variationId > 0 ? "{$productId}_{$variationId}" : (string) $productId;
$domainsByProduct[$key] = [
'domains' => $item['domains'],
'variation_id' => $variationId,
];
}
}
// Generate licenses for each licensed product
foreach ($order->get_items() as $item) {
$product = $item->get_product();
if ($product && $product->is_type('licensed')) {
$domain = $order->get_meta('_licensed_product_domain');
if ($domain) {
if (!$product || !$this->licenseManager->isLicensedProduct($product)) {
continue;
}
// Get the parent product ID (for variations, this is the main product)
$productId = $product->is_type('variation') ? $product->get_parent_id() : $product->get_id();
$variationId = $item->get_variation_id();
// Look up domains - first try with variation, then without
$key = $variationId > 0 ? "{$productId}_{$variationId}" : (string) $productId;
$domainInfo = $domainsByProduct[$key] ?? $domainsByProduct[(string) $productId] ?? null;
$domains = $domainInfo['domains'] ?? [];
// Generate a license for each domain
foreach ($domains as $domain) {
if (!empty($domain)) {
$this->licenseManager->generateLicense(
$orderId,
$product->get_id(),
$order->get_customer_id(),
$domain
$productId,
$customerId,
$domain,
$variationId > 0 ? $variationId : null
);
}
}
}
}
/**
* 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 && $this->licenseManager->isLicensedProduct($product)) {
// Get the parent product ID (for variations, this is the main product)
$productId = $product->is_type('variation') ? $product->get_parent_id() : $product->get_id();
$variationId = $item->get_variation_id();
$this->licenseManager->generateLicense(
$order->get_id(),
$productId,
$order->get_customer_id(),
$domain,
$variationId > 0 ? $variationId : null
);
}
}
}
/**
* Get Twig environment
*/

View File

@@ -12,7 +12,8 @@ namespace Jeremias\WcLicensedProduct\Product;
use Jeremias\WcLicensedProduct\Admin\SettingsController;
/**
* Registers and handles the Licensed product type for WooCommerce
* Registers and handles the Licensed product types for WooCommerce
* Supports both simple licensed products and variable licensed products
*/
final class LicensedProductType
{
@@ -29,7 +30,7 @@ final class LicensedProductType
*/
private function registerHooks(): void
{
// Register product type
// Register product types
add_filter('product_type_selector', [$this, 'addProductType']);
add_filter('woocommerce_product_class', [$this, 'getProductClass'], 10, 2);
@@ -39,9 +40,11 @@ final class LicensedProductType
// Save product meta
add_action('woocommerce_process_product_meta_licensed', [$this, 'saveProductMeta']);
add_action('woocommerce_process_product_meta_licensed-variable', [$this, 'saveProductMeta']);
// Show price and add to cart for licensed products
add_action('woocommerce_licensed_add_to_cart', [$this, 'addToCartTemplate']);
add_action('woocommerce_licensed-variable_add_to_cart', [$this, 'variableAddToCartTemplate']);
// Make product virtual by default
add_filter('woocommerce_product_is_virtual', [$this, 'isVirtual'], 10, 2);
@@ -51,25 +54,48 @@ final class LicensedProductType
// Enqueue frontend CSS for licensed products on single product pages
add_action('wp_enqueue_scripts', [$this, 'enqueueFrontendStyles']);
// Variable product support - variation settings
add_action('woocommerce_variation_options', [$this, 'addVariationOptions'], 10, 3);
add_action('woocommerce_product_after_variable_attributes', [$this, 'addVariationFields'], 10, 3);
add_action('woocommerce_save_product_variation', [$this, 'saveVariationFields'], 10, 2);
// Admin scripts for licensed-variable type
add_action('admin_footer', [$this, 'addVariableProductScripts']);
}
/**
* Add product type to selector
* Add product types to selector
*/
public function addProductType(array $types): array
{
$types['licensed'] = __('Licensed Product', 'wc-licensed-product');
$types['licensed-variable'] = __('Licensed Variable Product', 'wc-licensed-product');
return $types;
}
/**
* Get product class for licensed type
* Get product class for licensed types
*/
public function getProductClass(string $className, string $productType): string
{
if ($productType === 'licensed') {
return LicensedProduct::class;
}
if ($productType === 'licensed-variable') {
return LicensedVariableProduct::class;
}
// Handle variations of licensed-variable products
if ($productType === 'variation') {
// Check if parent is licensed-variable
global $post;
if ($post && $post->post_parent) {
$parentType = \WC_Product_Factory::get_product_type($post->post_parent);
if ($parentType === 'licensed-variable') {
return LicensedProductVariation::class;
}
}
}
return $className;
}
@@ -81,7 +107,7 @@ final class LicensedProductType
$tabs['licensed_product'] = [
'label' => __('License Settings', 'wc-licensed-product'),
'target' => 'licensed_product_data',
'class' => ['show_if_licensed'],
'class' => ['show_if_licensed', 'show_if_licensed-variable'],
'priority' => 21,
];
return $tabs;
@@ -236,9 +262,16 @@ final class LicensedProductType
*/
public function isVirtual(bool $isVirtual, \WC_Product $product): bool
{
if ($product->is_type('licensed')) {
if ($product->is_type('licensed') || $product->is_type('licensed-variable')) {
return true;
}
// Also handle variations of licensed-variable products
if ($product->is_type('variation') && $product->get_parent_id()) {
$parent = wc_get_product($product->get_parent_id());
if ($parent && $parent->is_type('licensed-variable')) {
return true;
}
}
return $isVirtual;
}
@@ -253,7 +286,7 @@ final class LicensedProductType
global $product;
if (!$product || !$product->is_type('licensed')) {
if (!$product || (!$product->is_type('licensed') && !$product->is_type('licensed-variable'))) {
return;
}
@@ -272,11 +305,11 @@ final class LicensedProductType
{
global $product;
if (!$product || !$product->is_type('licensed')) {
if (!$product || (!$product->is_type('licensed') && !$product->is_type('licensed-variable'))) {
return;
}
/** @var LicensedProduct $product */
/** @var LicensedProduct|LicensedVariableProduct $product */
$version = $product->get_current_version();
if (empty($version)) {
@@ -289,4 +322,200 @@ final class LicensedProductType
esc_html($version)
);
}
/**
* Add to cart template for variable licensed products
*/
public function variableAddToCartTemplate(): void
{
wc_get_template('single-product/add-to-cart/variable.php');
}
/**
* Add variation options (checkboxes next to variation header)
*/
public function addVariationOptions(int $loop, array $variationData, \WP_Post $variation): void
{
// Check if parent is licensed-variable
$parentId = $variation->post_parent;
$parentProduct = wc_get_product($parentId);
if (!$parentProduct || !$parentProduct->is_type('licensed-variable')) {
return;
}
$isVirtual = get_post_meta($variation->ID, '_virtual', true);
?>
<label class="tips" data-tip="<?php esc_attr_e('Licensed products are always virtual', 'wc-licensed-product'); ?>">
<input type="checkbox" class="checkbox" disabled checked />
<?php esc_html_e('Virtual', 'wc-licensed-product'); ?>
</label>
<?php
}
/**
* Add variation fields for license settings
*/
public function addVariationFields(int $loop, array $variationData, \WP_Post $variation): void
{
// Check if parent is licensed-variable
$parentId = $variation->post_parent;
$parentProduct = wc_get_product($parentId);
if (!$parentProduct || !$parentProduct->is_type('licensed-variable')) {
return;
}
// Get variation values
$validityDays = get_post_meta($variation->ID, '_licensed_validity_days', true);
$maxActivations = get_post_meta($variation->ID, '_licensed_max_activations', true);
// Get parent defaults for placeholder
$parentValidityDays = $parentProduct->get_validity_days();
$parentMaxActivations = $parentProduct->get_max_activations();
$parentValidityDisplay = $parentValidityDays !== null
? sprintf(__('%d days', 'wc-licensed-product'), $parentValidityDays)
: __('Lifetime', 'wc-licensed-product');
?>
<div class="wclp-variation-license-settings">
<p class="form-row form-row-first">
<label><?php esc_html_e('License Duration (Days)', 'wc-licensed-product'); ?></label>
<input type="number"
name="wclp_validity_days[<?php echo esc_attr($loop); ?>]"
class="short"
min="0"
step="1"
placeholder="<?php echo esc_attr($parentValidityDisplay); ?>"
value="<?php echo esc_attr($validityDays); ?>"
/>
<span class="description"><?php esc_html_e('Leave empty for parent default. 0 = Lifetime.', 'wc-licensed-product'); ?></span>
</p>
<p class="form-row form-row-last">
<label><?php esc_html_e('Max Activations', 'wc-licensed-product'); ?></label>
<input type="number"
name="wclp_max_activations[<?php echo esc_attr($loop); ?>]"
class="short"
min="1"
step="1"
placeholder="<?php echo esc_attr($parentMaxActivations); ?>"
value="<?php echo esc_attr($maxActivations); ?>"
/>
<span class="description"><?php esc_html_e('Leave empty for parent default.', 'wc-licensed-product'); ?></span>
</p>
</div>
<style>
.wclp-variation-license-settings {
background: #f8f8f8;
border: 1px solid #e5e5e5;
padding: 10px;
margin: 10px 0;
border-radius: 4px;
}
.wclp-variation-license-settings .description {
display: block;
font-style: italic;
color: #666;
margin-top: 4px;
}
</style>
<?php
}
/**
* Save variation fields
*/
public function saveVariationFields(int $variationId, int $loop): void
{
// Check if parent is licensed-variable
$variation = wc_get_product($variationId);
if (!$variation) {
return;
}
$parentProduct = wc_get_product($variation->get_parent_id());
if (!$parentProduct || !$parentProduct->is_type('licensed-variable')) {
return;
}
// Save validity days
// phpcs:ignore WordPress.Security.NonceVerification.Missing -- Nonce is verified by WooCommerce
if (isset($_POST['wclp_validity_days'][$loop])) {
// phpcs:ignore WordPress.Security.NonceVerification.Missing
$validityDays = sanitize_text_field($_POST['wclp_validity_days'][$loop]);
if ($validityDays !== '') {
update_post_meta($variationId, '_licensed_validity_days', absint($validityDays));
} else {
delete_post_meta($variationId, '_licensed_validity_days');
}
}
// Save max activations
// phpcs:ignore WordPress.Security.NonceVerification.Missing
if (isset($_POST['wclp_max_activations'][$loop])) {
// phpcs:ignore WordPress.Security.NonceVerification.Missing
$maxActivations = sanitize_text_field($_POST['wclp_max_activations'][$loop]);
if ($maxActivations !== '') {
update_post_meta($variationId, '_licensed_max_activations', absint($maxActivations));
} else {
delete_post_meta($variationId, '_licensed_max_activations');
}
}
// Set variation as virtual (licensed products are always virtual)
update_post_meta($variationId, '_virtual', 'yes');
}
/**
* Add JavaScript for licensed-variable product type in admin
*/
public function addVariableProductScripts(): void
{
global $post, $pagenow;
if ($pagenow !== 'post.php' && $pagenow !== 'post-new.php') {
return;
}
if (!$post || get_post_type($post) !== 'product') {
return;
}
?>
<script type="text/javascript">
jQuery(document).ready(function($) {
// Show/hide panels based on product type
function toggleLicensedVariableOptions() {
var productType = $('#product-type').val();
if (productType === 'licensed-variable') {
// Show variable product options
$('.show_if_variable').show();
$('.hide_if_variable').hide();
// Show licensed product options
$('.show_if_licensed-variable').show();
$('.show_if_licensed').show();
// Show general and variations tabs
$('.general_tab').show();
$('.variations_tab').show();
// Hide shipping tab (virtual products)
$('.shipping_tab').hide();
}
}
// Initial check
toggleLicensedVariableOptions();
// On product type change
$('#product-type').on('change', function() {
toggleLicensedVariableOptions();
});
});
</script>
<?php
}
}

View File

@@ -0,0 +1,196 @@
<?php
/**
* Licensed Product Variation Class
*
* @package Jeremias\WcLicensedProduct\Product
*/
declare(strict_types=1);
namespace Jeremias\WcLicensedProduct\Product;
use Jeremias\WcLicensedProduct\Admin\SettingsController;
use WC_Product_Variation;
/**
* Licensed Product Variation type extending WooCommerce Product Variation
*
* Each variation can have its own license duration settings.
*/
class LicensedProductVariation extends WC_Product_Variation
{
/**
* Constructor
*/
public function __construct($product = 0)
{
parent::__construct($product);
}
/**
* Licensed products are always virtual
*/
public function is_virtual(): bool
{
return true;
}
/**
* Get max activations for this variation
* Falls back to parent product, then to default settings
*/
public function get_max_activations(): int
{
// Check variation-specific setting first
$value = $this->get_meta('_licensed_max_activations', true);
if ($value !== '' && $value !== null) {
return max(1, (int) $value);
}
// Fall back to parent product
$parent = wc_get_product($this->get_parent_id());
if ($parent && method_exists($parent, 'get_max_activations')) {
return $parent->get_max_activations();
}
return SettingsController::getDefaultMaxActivations();
}
/**
* Check if variation has custom max activations set
*/
public function has_custom_max_activations(): bool
{
$value = $this->get_meta('_licensed_max_activations', true);
return $value !== '' && $value !== null;
}
/**
* Get validity days for this variation
* This is the primary license setting that varies per variation
* Falls back to parent product, then to default settings
*/
public function get_validity_days(): ?int
{
// Check variation-specific setting first
$value = $this->get_meta('_licensed_validity_days', true);
if ($value !== '' && $value !== null) {
$days = (int) $value;
// 0 means lifetime
return $days > 0 ? $days : null;
}
// Fall back to parent product
$parent = wc_get_product($this->get_parent_id());
if ($parent && method_exists($parent, 'get_validity_days')) {
return $parent->get_validity_days();
}
return SettingsController::getDefaultValidityDays();
}
/**
* Check if variation has custom validity days set
*/
public function has_custom_validity_days(): bool
{
$value = $this->get_meta('_licensed_validity_days', true);
return $value !== '' && $value !== null;
}
/**
* Check if license should be bound to major version
* Falls back to parent product, then to default settings
*/
public function is_bound_to_version(): bool
{
// Check variation-specific setting first
$value = $this->get_meta('_licensed_bind_to_version', true);
if ($value !== '' && $value !== null) {
return $value === 'yes';
}
// Fall back to parent product
$parent = wc_get_product($this->get_parent_id());
if ($parent && method_exists($parent, 'is_bound_to_version')) {
return $parent->is_bound_to_version();
}
return SettingsController::getDefaultBindToVersion();
}
/**
* Check if variation has custom bind to version setting
*/
public function has_custom_bind_to_version(): bool
{
$value = $this->get_meta('_licensed_bind_to_version', true);
return $value !== '' && $value !== null;
}
/**
* Get the license duration label for display
*/
public function get_license_duration_label(): string
{
$days = $this->get_validity_days();
if ($days === null) {
return __('Lifetime', 'wc-licensed-product');
}
if ($days === 30) {
return __('Monthly', 'wc-licensed-product');
}
if ($days === 90) {
return __('Quarterly', 'wc-licensed-product');
}
if ($days === 365) {
return __('Yearly', 'wc-licensed-product');
}
return sprintf(
/* translators: %d: number of days */
_n('%d day', '%d days', $days, 'wc-licensed-product'),
$days
);
}
/**
* Get current software version from parent product
*/
public function get_current_version(): string
{
$parent = wc_get_product($this->get_parent_id());
if ($parent && method_exists($parent, 'get_current_version')) {
return $parent->get_current_version();
}
$versionManager = new VersionManager();
$latestVersion = $versionManager->getLatestVersion($this->get_parent_id());
return $latestVersion ? $latestVersion->getVersion() : '';
}
/**
* Get major version number from parent product
*/
public function get_major_version(): int
{
$parent = wc_get_product($this->get_parent_id());
if ($parent && method_exists($parent, 'get_major_version')) {
return $parent->get_major_version();
}
$versionManager = new VersionManager();
$latestVersion = $versionManager->getLatestVersion($this->get_parent_id());
if ($latestVersion) {
return $latestVersion->getMajorVersion();
}
return 1;
}
}

View File

@@ -0,0 +1,151 @@
<?php
/**
* Licensed Variable Product Class
*
* @package Jeremias\WcLicensedProduct\Product
*/
declare(strict_types=1);
namespace Jeremias\WcLicensedProduct\Product;
use Jeremias\WcLicensedProduct\Admin\SettingsController;
use WC_Product_Variable;
/**
* Licensed Variable Product type extending WooCommerce Variable Product
*
* This allows selling license subscriptions with different durations
* (e.g., monthly, yearly, lifetime) as product variations.
*/
class LicensedVariableProduct extends WC_Product_Variable
{
/**
* Product type
*/
protected $product_type = 'licensed-variable';
/**
* Constructor
*/
public function __construct($product = 0)
{
parent::__construct($product);
}
/**
* Get product type
*/
public function get_type(): string
{
return 'licensed-variable';
}
/**
* Licensed products are always virtual
*/
public function is_virtual(): bool
{
return true;
}
/**
* Licensed products are purchasable
*/
public function is_purchasable(): bool
{
return $this->exists() && $this->get_price() !== '';
}
/**
* Get max activations for this product (parent default)
* Falls back to default settings if not set on product
*/
public function get_max_activations(): int
{
$value = $this->get_meta('_licensed_max_activations', true);
if ($value !== '' && $value !== null) {
return max(1, (int) $value);
}
return SettingsController::getDefaultMaxActivations();
}
/**
* Check if product has custom max activations set
*/
public function has_custom_max_activations(): bool
{
$value = $this->get_meta('_licensed_max_activations', true);
return $value !== '' && $value !== null;
}
/**
* Get validity days (parent default - variations override this)
* Falls back to default settings if not set on product
*/
public function get_validity_days(): ?int
{
$value = $this->get_meta('_licensed_validity_days', true);
if ($value !== '' && $value !== null) {
return (int) $value > 0 ? (int) $value : null;
}
return SettingsController::getDefaultValidityDays();
}
/**
* Check if product has custom validity days set
*/
public function has_custom_validity_days(): bool
{
$value = $this->get_meta('_licensed_validity_days', true);
return $value !== '' && $value !== null;
}
/**
* Check if license should be bound to major version
* Falls back to default settings if not set on product
*/
public function is_bound_to_version(): bool
{
$value = $this->get_meta('_licensed_bind_to_version', true);
if ($value !== '' && $value !== null) {
return $value === 'yes';
}
return SettingsController::getDefaultBindToVersion();
}
/**
* Check if product has custom bind to version setting
*/
public function has_custom_bind_to_version(): bool
{
$value = $this->get_meta('_licensed_bind_to_version', true);
return $value !== '' && $value !== null;
}
/**
* Get current software version (derived from latest product version)
*/
public function get_current_version(): string
{
$versionManager = new VersionManager();
$latestVersion = $versionManager->getLatestVersion($this->get_id());
return $latestVersion ? $latestVersion->getVersion() : '';
}
/**
* Get major version number from version string
*/
public function get_major_version(): int
{
$versionManager = new VersionManager();
$latestVersion = $versionManager->getLatestVersion($this->get_id());
if ($latestVersion) {
return $latestVersion->getMajorVersion();
}
return 1;
}
}

View File

@@ -1,81 +1,150 @@
{% if not has_licenses %}
{% if not has_packages %}
<p>{{ __('You have no licenses yet.') }}</p>
{% else %}
<div class="woocommerce-licenses">
{% for item in licenses %}
<div class="license-card">
<div class="license-header">
<h3>
{% if item.product_url %}
<a href="{{ esc_url(item.product_url) }}">{{ esc_html(item.product_name) }}</a>
{% else %}
{{ esc_html(item.product_name) }}
{% endif %}
</h3>
<span class="license-status license-status-{{ esc_attr(item.license.status) }}">
{{ esc_html(item.license.status)|capitalize }}
{% for package in packages %}
<div class="license-package">
<div class="package-header">
<div class="package-title">
<h3>
{% if package.product_url %}
<a href="{{ esc_url(package.product_url) }}">{{ esc_html(package.product_name) }}</a>
{% else %}
{{ esc_html(package.product_name) }}
{% endif %}
</h3>
<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>
</div>
<div class="license-details">
<div class="license-key-row">
<label>{{ __('License Key:') }}</label>
<code class="license-key" data-license-key="{{ esc_attr(item.license.licenseKey) }}">
{{ esc_html(item.license.licenseKey) }}
</code>
<button type="button" class="copy-license-btn" data-license-key="{{ esc_attr(item.license.licenseKey) }}" title="{{ __('Copy to clipboard') }}">
<span class="dashicons dashicons-clipboard"></span>
</button>
</div>
<div class="license-info-row">
<span class="license-domain-display" data-license-id="{{ item.license.id }}">
<strong>{{ __('Domain:') }}</strong>
<span class="domain-value">{{ esc_html(item.license.domain) }}</span>
{% if item.license.status == 'active' or item.license.status == 'inactive' %}
<button type="button" class="wclp-transfer-btn"
data-license-id="{{ item.license.id }}"
data-current-domain="{{ esc_attr(item.license.domain) }}"
title="{{ __('Transfer to new domain') }}">
<span class="dashicons dashicons-randomize"></span>
{{ __('Transfer') }}
</button>
<div class="package-licenses">
{% for license in package.licenses %}
<div class="license-entry license-entry-{{ esc_attr(license.status) }}">
<div class="license-row-primary">
<div class="license-key-group">
<code class="license-key">{{ esc_html(license.license_key) }}</code>
<span class="license-status license-status-{{ esc_attr(license.status) }}">
{{ esc_html(license.status)|capitalize }}
</span>
</div>
<div class="license-actions">
<button type="button" class="copy-license-btn" data-license-key="{{ esc_attr(license.license_key) }}" title="{{ __('Copy to clipboard') }}">
<span class="dashicons dashicons-clipboard"></span>
</button>
{% if license.is_transferable %}
<button type="button" class="wclp-transfer-btn"
data-license-id="{{ license.id }}"
data-current-domain="{{ esc_attr(license.domain) }}"
title="{{ __('Transfer to new domain') }}">
<span class="dashicons dashicons-randomize"></span>
</button>
{% endif %}
</div>
</div>
<div class="license-row-secondary">
<span class="license-meta-item license-domain">
<span class="dashicons dashicons-admin-site-alt3"></span>
{{ esc_html(license.domain) }}
</span>
<span class="license-meta-item license-expiry">
<span class="dashicons dashicons-calendar-alt"></span>
{% if license.expires_at %}
{{ license.expires_at|date('Y-m-d') }}
{% else %}
<span class="lifetime">{{ __('Lifetime') }}</span>
{% endif %}
</span>
</div>
{% if signing_enabled and license.customer_secret %}
<div class="license-row-secret">
<button type="button" class="secret-toggle" aria-expanded="false">
<span class="dashicons dashicons-lock"></span>
{{ __('API Verification Secret') }}
<span class="dashicons dashicons-arrow-down-alt2 toggle-arrow"></span>
</button>
<div class="secret-content" style="display: none;">
<p class="secret-description">
{{ __('Use this secret to verify signed API responses. Keep it secure.') }}
</p>
<div class="secret-value-wrapper">
<code class="secret-value">{{ esc_html(license.customer_secret) }}</code>
<button type="button" class="copy-secret-btn" data-secret="{{ esc_attr(license.customer_secret) }}" title="{{ __('Copy to clipboard') }}">
<span class="dashicons dashicons-clipboard"></span>
</button>
</div>
</div>
</div>
{% endif %}
</span>
<span><strong>{{ __('Expires:') }}</strong>
{% if item.license.expiresAt %}
{{ item.license.expiresAt|date('Y-m-d') }}
{% else %}
{{ __('Never') }}
{% endif %}
</span>
</div>
</div>
{% endfor %}
</div>
{% if item.downloads is defined and item.downloads is not empty %}
<div class="license-downloads">
{% if package.downloads is defined and package.downloads is not empty %}
<div class="package-downloads">
<h4>{{ __('Available Downloads') }}</h4>
<ul class="download-list">
{% for download in item.downloads %}
<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 %}
{# Show only the latest version (first item) #}
{% set latest = package.downloads|first %}
<li class="download-item download-item-latest">
<div class="download-row-file">
<a href="{{ esc_url(latest.download_url) }}" class="download-link">
<span class="dashicons dashicons-download"></span>
{{ esc_html(latest.filename ?: 'Version ' ~ latest.version) }}
</a>
<span class="download-version-badge">{{ __('Latest') }}</span>
</div>
<div class="download-row-meta">
<span class="download-date">{{ esc_html(latest.released_at) }}</span>
{% if latest.file_hash %}
<span class="download-hash" title="{{ esc_attr(latest.file_hash) }}">
<span class="dashicons dashicons-shield"></span>
<code>{{ latest.file_hash[:12] }}...</code>
</span>
{% endif %}
</div>
</li>
</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>
{% endif %}
</div>

View File

@@ -3,7 +3,7 @@
* Plugin Name: WooCommerce 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.
* Version: 0.3.9
* Version: 0.5.3
* Author: Marco Graetsch
* Author URI: https://src.bundespruefstelle.ch/magdev
* License: GPL-2.0-or-later
@@ -28,7 +28,7 @@ if (!defined('ABSPATH')) {
}
// Plugin constants
define('WC_LICENSED_PRODUCT_VERSION', '0.3.9');
define('WC_LICENSED_PRODUCT_VERSION', '0.5.3');
define('WC_LICENSED_PRODUCT_PLUGIN_FILE', __FILE__);
define('WC_LICENSED_PRODUCT_PLUGIN_DIR', plugin_dir_path(__FILE__));
define('WC_LICENSED_PRODUCT_PLUGIN_URL', plugin_dir_url(__FILE__));