25 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
4a90e6b18b Bump version to 0.3.9
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-24 16:08:41 +01:00
502a8c7cd7 Update translation template with current line references
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-24 16:07:46 +01:00
6b83fce8b2 Fix admin order license generation bug
- Add 'Generate Licenses' button to order meta box for admin-created orders
- Add AJAX handler for manual license generation
- Show warning when domain is not set or order is not paid
- Handle partial license generation (when some products already have licenses)
- Update German translations for new strings (365 translated)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-24 16:06:13 +01:00
8c33eaff29 Clean up known bugs section after v0.3.8 fix
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-24 15:38:19 +01:00
98002ae3d7 Update CLAUDE.md with v0.3.8 session history
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-24 15:37:14 +01:00
37 changed files with 5375 additions and 1618 deletions

View File

@@ -7,6 +7,126 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased] ## [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
- "Generate Licenses" button in order meta box for admin-created orders
- "Generate Missing Licenses" button when some products in an order are missing licenses
- AJAX handler `ajaxGenerateOrderLicenses()` for manual license generation from admin
- Warning message when order domain is not set before generating licenses
### Fixed
- **Critical:** Licenses are now generated for orders created manually in admin area
- Previously, licenses were only generated via checkout hooks, leaving admin-created orders without licenses
### Technical Details
- Added `wclp_generate_order_licenses` AJAX action to `OrderLicenseController`
- Updated `order-licenses.js` with generate button handler and page reload on success
- Added CSS styles for generate status messages
- Updated translations (365 strings)
## [0.3.8] - 2026-01-24 ## [0.3.8] - 2026-01-24
### Fixed ### Fixed

307
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. **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. *No planned features yet.*
### Version 0.3.8
No changes at the moment.
### Version 0.4.0
No changes at the moment.
## Technical Stack ## Technical Stack
@@ -1091,3 +1083,298 @@ Fixed dashboard widget bugs, improved UI consistency, and added download trackin
- Created release package: `releases/wc-licensed-product-0.3.7.zip` (827 KB) - Created release package: `releases/wc-licensed-product-0.3.7.zip` (827 KB)
- SHA256: `e93b2ab06f6d43c2179167090e07eda5db6809df6e391baece4ceba321cf33f6` - SHA256: `e93b2ab06f6d43c2179167090e07eda5db6809df6e391baece4ceba321cf33f6`
- Tagged as `v0.3.7` and pushed to `main` branch - Tagged as `v0.3.7` and pushed to `main` branch
### 2026-01-24 - Version 0.3.8 - Translation Bug Fix
**Overview:**
Fixed a critical translation bug that caused the settings page to crash with an `ArgumentCountError`.
**Bug Fix:**
- Fixed: Duplicate German translation string in `wc-licensed-product-de_CH.po` causing `ArgumentCountError` in settings page
- Root cause: The notification settings description was duplicated in the translation, resulting in two `%s` placeholders when only one argument was passed to `sprintf()`
- Location: [wc-licensed-product-de_CH.po:322-328](languages/wc-licensed-product-de_CH.po#L322-L328)
**Modified files:**
- `languages/wc-licensed-product-de_CH.po` - Removed duplicated translation string
- `languages/wc-licensed-product-de_CH.mo` - Recompiled binary translation
**Technical notes:**
- Error was logged to `tmp/fatal-errors-2026-01-24.log`
- The German `msgstr` contained the same text twice, each with a `%s` placeholder
- `sprintf()` at `SettingsController.php:221` only provided one argument for the single `%s` in the English source
- Translation strings with `%s` placeholders must have exactly matching placeholder counts between source and translation
**Dependency Updates:**
- Updated `magdev/wc-licensed-product-client` from `9f513a8` to `64d215c`
**Release v0.3.8:**
- 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 { .licenses-table .row-actions {
visibility: visible; visibility: visible !important;
position: static !important;
padding: 2px 0 0; padding: 2px 0 0;
} }

View File

@@ -37,13 +37,196 @@
color: #383d41; color: #383d41;
} }
/* License Cards */ /* License Packages */
.woocommerce-licenses { .woocommerce-licenses {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 1.5em; gap: 1.5em;
} }
.license-package {
border: 1px solid #e5e5e5;
border-radius: 8px;
overflow: hidden;
background: #fff;
}
.package-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1em 1.5em;
background: #f8f9fa;
border-bottom: 1px solid #e5e5e5;
}
.package-title {
display: flex;
flex-direction: column;
gap: 0.25em;
}
.package-title h3 {
margin: 0;
font-size: 1.1em;
}
.package-title h3 a {
color: inherit;
text-decoration: none;
}
.package-title h3 a:hover {
text-decoration: underline;
}
.package-order {
font-size: 0.85em;
color: #666;
}
.package-order a {
color: #2271b1;
text-decoration: none;
}
.package-order a:hover {
text-decoration: underline;
}
.package-license-count {
font-size: 0.9em;
color: #666;
background: #e9ecef;
padding: 0.3em 0.8em;
border-radius: 12px;
}
/* Package Licenses - Two Row Layout */
.package-licenses {
padding: 0;
}
.license-entry {
padding: 1em 1.5em;
border-bottom: 1px solid #e5e5e5;
}
.license-entry:last-child {
border-bottom: none;
}
.license-entry:hover {
background-color: #fafafa;
}
.license-row-primary {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1em;
margin-bottom: 0.5em;
}
.license-key-group {
display: flex;
align-items: center;
gap: 0.75em;
flex-shrink: 1;
min-width: 0;
}
.license-entry code.license-key {
font-family: 'SF Mono', Monaco, Consolas, monospace;
background-color: #f5f5f5;
padding: 0.4em 0.75em;
border-radius: 4px;
font-size: 0.95em;
letter-spacing: 0.03em;
flex-shrink: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
}
.license-key-group .license-status {
flex-shrink: 0;
}
.license-actions {
display: flex;
align-items: center;
gap: 0.5em;
flex-shrink: 0;
}
.license-row-secondary {
display: flex;
align-items: center;
gap: 1.5em;
font-size: 0.9em;
color: #666;
flex-wrap: wrap;
}
.license-meta-item {
display: inline-flex;
align-items: center;
gap: 0.35em;
}
.license-meta-item .dashicons {
font-size: 14px;
width: 14px;
height: 14px;
color: #999;
}
.license-domain {
color: #333;
}
.license-expiry .lifetime {
color: #28a745;
font-weight: 500;
}
/* Legacy table styles (kept for backwards compatibility) */
.licenses-table {
width: 100%;
border-collapse: collapse;
font-size: 0.95em;
}
.licenses-table th,
.licenses-table td {
padding: 0.75em 1em;
text-align: left;
border-bottom: 1px solid #e5e5e5;
}
.licenses-table th {
font-weight: 600;
background-color: #fafafa;
font-size: 0.9em;
color: #555;
}
.licenses-table code.license-key {
font-family: 'SF Mono', Monaco, Consolas, monospace;
background-color: #f5f5f5;
padding: 0.3em 0.6em;
border-radius: 4px;
font-size: 0.9em;
letter-spacing: 0.03em;
}
.licenses-table .lifetime {
color: #28a745;
font-weight: 500;
}
/* Legacy single card styles (kept for backwards compatibility) */
.license-card { .license-card {
border: 1px solid #e5e5e5; border: 1px solid #e5e5e5;
border-radius: 8px; border-radius: 8px;
@@ -184,12 +367,14 @@
} }
/* Download Section */ /* Download Section */
.package-downloads,
.license-downloads { .license-downloads {
padding: 1em 1.5em; padding: 1em 1.5em;
background: #f8f9fa; background: #f8f9fa;
border-top: 1px solid #e5e5e5; border-top: 1px solid #e5e5e5;
} }
.package-downloads h4,
.license-downloads h4 { .license-downloads h4 {
margin: 0 0 0.75em 0; margin: 0 0 0.75em 0;
font-size: 0.95em; font-size: 0.95em;
@@ -282,6 +467,71 @@
color: #666; color: #666;
} }
/* Latest version badge */
.download-version-badge {
display: inline-block;
padding: 0.15em 0.5em;
margin-left: 0.5em;
font-size: 0.75em;
font-weight: 600;
text-transform: uppercase;
background: #d4edda;
color: #155724;
border-radius: 3px;
vertical-align: middle;
}
/* Older versions collapsible */
.older-versions-section {
margin-top: 0.75em;
padding-top: 0.75em;
border-top: 1px dashed #ddd;
}
.older-versions-toggle {
display: inline-flex;
align-items: center;
gap: 0.35em;
padding: 0.4em 0.75em;
background: transparent;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 0.85em;
color: #666;
cursor: pointer;
transition: all 0.2s ease;
}
.older-versions-toggle:hover {
background: #f5f5f5;
border-color: #ccc;
color: #333;
}
.older-versions-toggle .dashicons {
font-size: 16px;
width: 16px;
height: 16px;
transition: transform 0.2s ease;
}
.older-versions-toggle[aria-expanded="true"] .dashicons {
transform: rotate(180deg);
}
.older-versions-list {
margin-top: 0.75em;
padding-left: 0;
}
.older-versions-list .download-item {
opacity: 0.85;
}
.older-versions-list .download-item:hover {
opacity: 1;
}
/* Domain Field */ /* Domain Field */
#licensed-product-domain-field { #licensed-product-domain-field {
margin-top: 2em; margin-top: 2em;
@@ -333,6 +583,52 @@
/* Responsive */ /* Responsive */
@media screen and (max-width: 768px) { @media screen and (max-width: 768px) {
/* Package header responsive */
.package-header {
flex-direction: column;
align-items: flex-start;
gap: 0.75em;
}
.package-license-count {
align-self: flex-start;
}
/* License entry responsive */
.license-entry {
padding: 1em;
}
.license-row-primary {
flex-direction: column;
align-items: flex-start;
gap: 0.75em;
}
.license-key-group {
flex-direction: column;
align-items: flex-start;
gap: 0.5em;
width: 100%;
}
.license-entry code.license-key {
font-size: 0.85em;
word-break: break-all;
white-space: normal;
}
.license-actions {
align-self: flex-start;
}
.license-row-secondary {
flex-direction: column;
align-items: flex-start;
gap: 0.5em;
}
/* Legacy card layout responsive */
.license-header { .license-header {
flex-direction: column; flex-direction: column;
align-items: flex-start; align-items: flex-start;
@@ -354,33 +650,44 @@
flex-wrap: wrap; flex-wrap: wrap;
} }
/* Legacy table responsive */
.woocommerce-licenses-table, .woocommerce-licenses-table,
.woocommerce-licenses-table thead, .woocommerce-licenses-table thead,
.woocommerce-licenses-table tbody, .woocommerce-licenses-table tbody,
.woocommerce-licenses-table th, .woocommerce-licenses-table th,
.woocommerce-licenses-table td, .woocommerce-licenses-table td,
.woocommerce-licenses-table tr { .woocommerce-licenses-table tr,
.licenses-table,
.licenses-table thead,
.licenses-table tbody,
.licenses-table th,
.licenses-table td,
.licenses-table tr {
display: block; display: block;
} }
.woocommerce-licenses-table thead tr { .woocommerce-licenses-table thead tr,
.licenses-table thead tr {
position: absolute; position: absolute;
top: -9999px; top: -9999px;
left: -9999px; left: -9999px;
} }
.woocommerce-licenses-table tr { .woocommerce-licenses-table tr,
.licenses-table tr {
border: 1px solid #e5e5e5; border: 1px solid #e5e5e5;
margin-bottom: 1em; margin-bottom: 1em;
} }
.woocommerce-licenses-table td { .woocommerce-licenses-table td,
.licenses-table td {
border: none; border: none;
position: relative; position: relative;
padding-left: 50%; padding-left: 50%;
} }
.woocommerce-licenses-table td:before { .woocommerce-licenses-table td:before,
.licenses-table td:before {
content: attr(data-title); content: attr(data-title);
position: absolute; position: absolute;
left: 0.75em; left: 0.75em;
@@ -556,3 +863,118 @@
color: #2271b1; color: #2271b1;
font-weight: 500; 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 * WooCommerce Checkout Blocks Integration
* *
* Adds a domain field to the checkout block for licensed products. * Adds domain fields to the checkout block for licensed products.
* Supports single domain mode (legacy) and multi-domain mode (per quantity).
* *
* @package WcLicensedProduct * @package WcLicensedProduct
*/ */
@@ -9,92 +10,375 @@
(function () { (function () {
'use strict'; 'use strict';
const { registerCheckoutBlock } = wc.blocksCheckout; // Check dependencies
const { createElement, useState, useEffect } = wp.element; if (typeof wc === 'undefined' ||
typeof wc.blocksCheckout === 'undefined' ||
typeof wc.wcSettings === 'undefined') {
return;
}
const { getSetting } = wc.wcSettings;
const { createElement, useState } = wp.element;
const { TextControl } = wp.components; const { TextControl } = wp.components;
const { __ } = wp.i18n; const { __ } = wp.i18n;
const { extensionCartUpdate } = wc.blocksCheckout;
const { getSetting } = wc.wcSettings;
// Get settings passed from PHP // Get available exports from blocksCheckout
const { ExperimentalOrderMeta } = wc.blocksCheckout;
// Get settings from PHP
const settings = getSetting('wc-licensed-product_data', {}); const settings = getSetting('wc-licensed-product_data', {});
// Check if we have licensed products
if (!settings.hasLicensedProducts) {
return;
}
/** /**
* Validate domain format * Validate domain format
*/ */
function isValidDomain(domain) { function isValidDomain(domain) {
if (!domain || domain.length > 255) { if (!domain || domain.length > 255) return false;
return false;
}
const pattern = /^([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}$/; const pattern = /^([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}$/;
return pattern.test(domain); return pattern.test(domain);
} }
/** /**
* Normalize domain (remove protocol and www) * Normalize domain
*/ */
function normalizeDomain(domain) { function normalizeDomain(domain) {
let normalized = domain.toLowerCase().trim(); return domain.toLowerCase().trim()
normalized = normalized.replace(/^https?:\/\//, ''); .replace(/^https?:\/\//, '')
normalized = normalized.replace(/^www\./, ''); .replace(/^www\./, '')
normalized = normalized.replace(/\/.*$/, ''); .replace(/\/.*$/, '');
return normalized;
} }
/** /**
* License Domain Block Component * Single Domain Component
*/ */
const LicenseDomainBlock = ({ checkoutExtensionData, extensions }) => { const SingleDomainField = () => {
const [domain, setDomain] = useState(''); const [domain, setDomain] = useState('');
const [error, setError] = useState(''); const [error, setError] = useState('');
const { setExtensionData } = checkoutExtensionData;
// Only show if cart has licensed products
if (!settings.hasLicensedProducts) {
return null;
}
const handleChange = (value) => { const handleChange = (value) => {
const normalized = normalizeDomain(value); const normalized = normalizeDomain(value);
setDomain(normalized); setDomain(normalized);
// Validate
if (normalized && !isValidDomain(normalized)) { if (normalized && !isValidDomain(normalized)) {
setError(settings.validationError || __('Please enter a valid domain.', 'wc-licensed-product')); setError(settings.validationError || __('Please enter a valid domain.', 'wc-licensed-product'));
} else { } else {
setError(''); setError('');
} }
// Update extension data for server-side processing // Store in hidden input for form submission
setExtensionData('wc-licensed-product', 'licensed_product_domain', normalized); const hiddenInput = document.getElementById('wclp-domain-hidden');
if (hiddenInput) {
hiddenInput.value = normalized;
}
}; };
return createElement( return createElement(
'div', 'div',
{ className: 'wc-block-components-licensed-product-domain' }, {
createElement( className: 'wc-block-components-licensed-product-domain',
'h3', style: {
{ className: 'wc-block-components-title' }, padding: '16px',
backgroundColor: '#f0f0f0',
borderRadius: '4px',
marginBottom: '16px',
}
},
createElement('h4', { style: { marginTop: 0, marginBottom: '8px' } },
settings.sectionTitle || __('License Domain', 'wc-licensed-product') settings.sectionTitle || __('License Domain', 'wc-licensed-product')
), ),
createElement('p', { style: { marginBottom: '12px', color: '#666', fontSize: '0.9em' } },
settings.fieldDescription || __('Enter the domain where you will use the license.', 'wc-licensed-product')
),
createElement(TextControl, { createElement(TextControl, {
label: settings.fieldLabel || __('Domain for License Activation', 'wc-licensed-product'), label: settings.singleDomainLabel || __('Domain', 'wc-licensed-product'),
value: domain, value: domain,
onChange: handleChange, onChange: handleChange,
placeholder: settings.fieldPlaceholder || 'example.com', placeholder: settings.fieldPlaceholder || 'example.com',
help: error || settings.fieldDescription || __('Enter the domain where you will use this license.', 'wc-licensed-product'), help: error || '',
className: error ? 'has-error' : '', className: error ? 'has-error' : '',
required: true, }),
createElement('input', {
type: 'hidden',
id: 'wclp-domain-hidden',
name: 'wclp_license_domain',
value: domain,
}) })
); );
}; };
// Register the checkout block /**
registerCheckoutBlock({ * Get unique key for product (handles variations)
metadata: { */
name: 'wc-licensed-product/domain-field', function getProductKey(product) {
parent: ['woocommerce/checkout-contact-information-block'], if (product.variation_id && product.variation_id > 0) {
}, return `${product.product_id}_${product.variation_id}`;
component: LicenseDomainBlock, }
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() { bindEvents: function() {
$(document).on('click', '.copy-license-btn', this.copyLicenseKey); $(document).on('click', '.copy-license-btn', this.copyLicenseKey);
$(document).on('click', '.copy-secret-btn', this.copySecret);
// Transfer modal events // Transfer modal events
$(document).on('click', '.wclp-transfer-btn', this.openTransferModal.bind(this)); $(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('click', '.wclp-modal-close, .wclp-modal-cancel, .wclp-modal-overlay', this.closeTransferModal.bind(this));
$(document).on('submit', '#wclp-transfer-form', this.submitTransfer.bind(this)); $(document).on('submit', '#wclp-transfer-form', this.submitTransfer.bind(this));
// Older versions toggle
$(document).on('click', '.older-versions-toggle', this.toggleOlderVersions);
// Secret toggle
$(document).on('click', '.secret-toggle', this.toggleSecret);
// Close modal on escape key // Close modal on escape key
$(document).on('keyup', function(e) { $(document).on('keyup', function(e) {
if (e.key === 'Escape') { if (e.key === 'Escape') {
@@ -33,6 +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 * Copy license key to clipboard
*/ */

View File

@@ -16,6 +16,9 @@
// Order domain save // Order domain save
$('#wclp-save-order-domain').on('click', this.saveOrderDomain.bind(this)); $('#wclp-save-order-domain').on('click', this.saveOrderDomain.bind(this));
// Generate licenses button
$(document).on('click', '#wclp-generate-licenses', this.generateLicenses.bind(this));
// License domain edit/save/cancel // License domain edit/save/cancel
$(document).on('click', '.wclp-edit-domain-btn', this.startEditDomain); $(document).on('click', '.wclp-edit-domain-btn', this.startEditDomain);
$(document).on('click', '.wclp-save-domain-btn', this.saveLicenseDomain.bind(this)); $(document).on('click', '.wclp-save-domain-btn', this.saveLicenseDomain.bind(this));
@@ -135,6 +138,54 @@
$editBtn.show(); $editBtn.show();
}, },
/**
* Generate licenses for order
*/
generateLicenses: function(e) {
e.preventDefault();
var $btn = $(e.currentTarget);
var $spinner = $btn.siblings('.spinner');
var $status = $btn.siblings('.wclp-generate-status');
var orderId = $btn.data('order-id');
$btn.prop('disabled', true);
$spinner.addClass('is-active');
$status.text('').removeClass('success error');
$.ajax({
url: wclpOrderLicenses.ajaxUrl,
type: 'POST',
data: {
action: 'wclp_generate_order_licenses',
nonce: wclpOrderLicenses.nonce,
order_id: orderId
},
success: function(response) {
if (response.success) {
$status.text(response.data.message).addClass('success');
if (response.data.reload) {
// Reload the page after a short delay to show the new licenses
setTimeout(function() {
window.location.reload();
}, 1500);
}
} else {
$status.text(response.data.message || wclpOrderLicenses.strings.error).addClass('error');
$btn.prop('disabled', false);
}
},
error: function() {
$status.text(wclpOrderLicenses.strings.error).addClass('error');
$btn.prop('disabled', false);
},
complete: function() {
$spinner.removeClass('is-active');
}
});
},
/** /**
* Save license domain * Save license domain
*/ */

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 * Extract version from filename
* Supports patterns like: plugin-v1.2.3.zip, plugin-1.2.3.zip, v1.2.3.zip * 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 // Remove "no versions" row if present
$('#versions-table tbody .no-versions').remove(); $('#versions-table tbody .no-versions').remove();
// Add new row to table // Add new row in sorted position (by version DESC)
$('#versions-table tbody').prepend(response.data.html); 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 // Clear form
$('#new_version').val(''); $('#new_version').val('');

12
composer.lock generated
View File

@@ -380,16 +380,16 @@
}, },
{ {
"name": "symfony/http-client", "name": "symfony/http-client",
"version": "v7.4.3", "version": "v7.4.4",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/symfony/http-client.git", "url": "https://github.com/symfony/http-client.git",
"reference": "d01dfac1e0dc99f18da48b18101c23ce57929616" "reference": "d63c23357d74715a589454c141c843f0172bec6c"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/symfony/http-client/zipball/d01dfac1e0dc99f18da48b18101c23ce57929616", "url": "https://api.github.com/repos/symfony/http-client/zipball/d63c23357d74715a589454c141c843f0172bec6c",
"reference": "d01dfac1e0dc99f18da48b18101c23ce57929616", "reference": "d63c23357d74715a589454c141c843f0172bec6c",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@@ -457,7 +457,7 @@
"http" "http"
], ],
"support": { "support": {
"source": "https://github.com/symfony/http-client/tree/v7.4.3" "source": "https://github.com/symfony/http-client/tree/v7.4.4"
}, },
"funding": [ "funding": [
{ {
@@ -477,7 +477,7 @@
"type": "tidelift" "type": "tidelift"
} }
], ],
"time": "2025-12-23T14:50:43+00:00" "time": "2026-01-23T16:34:22+00:00"
}, },
{ {
"name": "symfony/http-client-contracts", "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 1. Server generates a unique signature for each response using HMAC-SHA256
2. Signature includes a timestamp to prevent replay attacks 2. Signature includes a timestamp to prevent replay attacks
3. Client verifies the signature using a shared secret 3. Each license key has a unique derived secret (not the master secret)
4. Invalid signatures cause the client to reject the response 4. Client verifies the signature using their per-license secret
5. Invalid signatures cause the client to reject the response
This prevents attackers from: This prevents attackers from:
- Faking valid license responses - Faking valid license responses
- Replaying old responses - Replaying old responses
- Tampering with response data - Tampering with response data
- Using one customer's secret to verify another customer's responses
## Requirements ## Requirements
@@ -323,13 +325,49 @@ Adjust if needed:
$signature = new ResponseSignature($key, timestampTolerance: 600); // 10 minutes $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 ### Secret Key Rotation
To rotate the server secret: To rotate the server secret:
1. Deploy new secret to server 1. Deploy new secret to server
2. Update client configurations 2. All per-license secrets change automatically (they're derived)
3. Old signatures become invalid immediately 3. Customers must copy their new secret from their account page
4. Old signatures become invalid immediately
For zero-downtime rotation, implement versioned secrets: 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

@@ -36,6 +36,7 @@ final class OrderLicenseController
// Handle AJAX actions // Handle AJAX actions
add_action('wp_ajax_wclp_update_order_domain', [$this, 'ajaxUpdateOrderDomain']); add_action('wp_ajax_wclp_update_order_domain', [$this, 'ajaxUpdateOrderDomain']);
add_action('wp_ajax_wclp_update_license_domain', [$this, 'ajaxUpdateLicenseDomain']); add_action('wp_ajax_wclp_update_license_domain', [$this, 'ajaxUpdateLicenseDomain']);
add_action('wp_ajax_wclp_generate_order_licenses', [$this, 'ajaxGenerateOrderLicenses']);
// Enqueue admin scripts // Enqueue admin scripts
add_action('admin_enqueue_scripts', [$this, 'enqueueScripts']); add_action('admin_enqueue_scripts', [$this, 'enqueueScripts']);
@@ -93,8 +94,10 @@ final class OrderLicenseController
return; return;
} }
// Get order domain // Check for multi-domain format first, then fall back to legacy single domain
$orderDomain = $order->get_meta('_licensed_product_domain'); $multiDomainData = $order->get_meta('_licensed_product_domains');
$legacyDomain = $order->get_meta('_licensed_product_domain');
$hasMultiDomain = !empty($multiDomainData) && is_array($multiDomainData);
// Get licenses for this order // Get licenses for this order
$licenses = $this->licenseManager->getLicensesByOrder($order->get_id()); $licenses = $this->licenseManager->getLicensesByOrder($order->get_id());
@@ -103,7 +106,25 @@ final class OrderLicenseController
?> ?>
<div class="wclp-order-licenses"> <div class="wclp-order-licenses">
<div class="wclp-order-domain-section"> <div class="wclp-order-domain-section">
<h4><?php esc_html_e('Order Domain', 'wc-licensed-product'); ?></h4> <h4><?php esc_html_e('Order Domains', 'wc-licensed-product'); ?></h4>
<?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"> <p class="description">
<?php esc_html_e('The domain specified during checkout. Changing this will not automatically update existing license domains.', 'wc-licensed-product'); ?> <?php esc_html_e('The domain specified during checkout. Changing this will not automatically update existing license domains.', 'wc-licensed-product'); ?>
</p> </p>
@@ -111,7 +132,7 @@ final class OrderLicenseController
<input type="text" <input type="text"
id="wclp-order-domain" id="wclp-order-domain"
class="regular-text" class="regular-text"
value="<?php echo esc_attr($orderDomain); ?>" value="<?php echo esc_attr($legacyDomain); ?>"
data-order-id="<?php echo esc_attr($order->get_id()); ?>" data-order-id="<?php echo esc_attr($order->get_id()); ?>"
placeholder="<?php esc_attr_e('example.com', 'wc-licensed-product'); ?>" /> placeholder="<?php esc_attr_e('example.com', 'wc-licensed-product'); ?>" />
<button type="button" class="button" id="wclp-save-order-domain"> <button type="button" class="button" id="wclp-save-order-domain">
@@ -120,12 +141,36 @@ final class OrderLicenseController
<span class="spinner"></span> <span class="spinner"></span>
<span class="wclp-status-message"></span> <span class="wclp-status-message"></span>
</div> </div>
<?php endif; ?>
</div> </div>
<hr /> <hr />
<h4><?php esc_html_e('Licenses', 'wc-licensed-product'); ?></h4> <h4><?php esc_html_e('Licenses', 'wc-licensed-product'); ?></h4>
<?php
// 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 = $expectedLicenses - count($licenses);
$hasDomainData = $hasMultiDomain || !empty($legacyDomain);
?>
<?php if (empty($licenses)): ?> <?php if (empty($licenses)): ?>
<p class="description"> <p class="description">
<?php esc_html_e('No licenses have been generated for this order yet.', 'wc-licensed-product'); ?> <?php esc_html_e('No licenses have been generated for this order yet.', 'wc-licensed-product'); ?>
@@ -137,6 +182,20 @@ final class OrderLicenseController
<em><?php esc_html_e('Licenses will be generated when the order is marked as paid/completed.', 'wc-licensed-product'); ?></em> <em><?php esc_html_e('Licenses will be generated when the order is marked as paid/completed.', 'wc-licensed-product'); ?></em>
<?php endif; ?> <?php endif; ?>
</p> </p>
<?php if ($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'); ?>
</button>
<span class="spinner" style="float: none; margin-top: 4px;"></span>
<span class="wclp-generate-status"></span>
</p>
<?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'); ?>
</p>
<?php endif; ?>
<?php else: ?> <?php else: ?>
<table class="widefat striped wclp-licenses-table"> <table class="widefat striped wclp-licenses-table">
<thead> <thead>
@@ -223,6 +282,29 @@ final class OrderLicenseController
); );
?> ?>
</p> </p>
<?php if ($missingLicenses > 0 && $hasDomainData && $order->is_paid()): ?>
<p style="margin-top: 10px;">
<span class="dashicons dashicons-warning" style="color: #dba617;"></span>
<?php
printf(
/* translators: %d: Number of missing licenses */
esc_html(_n(
'%d licensed product is missing a license.',
'%d licensed products are missing licenses.',
$missingLicenses,
'wc-licensed-product'
)),
$missingLicenses
);
?>
<button type="button" class="button" id="wclp-generate-licenses" data-order-id="<?php echo esc_attr($order->get_id()); ?>">
<?php esc_html_e('Generate Missing Licenses', 'wc-licensed-product'); ?>
</button>
<span class="spinner" style="float: none; margin-top: 4px;"></span>
<span class="wclp-generate-status"></span>
</p>
<?php endif; ?>
<?php endif; ?> <?php endif; ?>
</div> </div>
@@ -248,6 +330,9 @@ final class OrderLicenseController
.wclp-lifetime { color: #0073aa; font-weight: 500; } .wclp-lifetime { color: #0073aa; font-weight: 500; }
.wclp-edit-domain-btn { color: #0073aa; text-decoration: none; } .wclp-edit-domain-btn { color: #0073aa; text-decoration: none; }
.wclp-edit-domain-btn .dashicons { font-size: 16px; width: 16px; height: 16px; } .wclp-edit-domain-btn .dashicons { font-size: 16px; width: 16px; height: 16px; }
.wclp-generate-status { font-style: italic; margin-left: 8px; }
.wclp-generate-status.success { color: #46b450; }
.wclp-generate-status.error { color: #dc3232; }
</style> </style>
<?php <?php
} }
@@ -284,8 +369,9 @@ final class OrderLicenseController
'strings' => [ 'strings' => [
'saving' => __('Saving...', 'wc-licensed-product'), 'saving' => __('Saving...', 'wc-licensed-product'),
'saved' => __('Saved!', 'wc-licensed-product'), 'saved' => __('Saved!', 'wc-licensed-product'),
'error' => __('Error saving. Please try again.', 'wc-licensed-product'), 'error' => __('Error. Please try again.', 'wc-licensed-product'),
'invalidDomain' => __('Please enter a valid domain.', 'wc-licensed-product'), 'invalidDomain' => __('Please enter a valid domain.', 'wc-licensed-product'),
'generating' => __('Generating...', 'wc-licensed-product'),
], ],
]); ]);
} }
@@ -392,4 +478,166 @@ final class OrderLicenseController
$pattern = '/^([a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}$/'; $pattern = '/^([a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}$/';
return (bool) preg_match($pattern, $domain); return (bool) preg_match($pattern, $domain);
} }
/**
* AJAX handler for generating order licenses
*/
public function ajaxGenerateOrderLicenses(): void
{
check_ajax_referer('wclp_order_license_actions', 'nonce');
if (!current_user_can('manage_woocommerce')) {
wp_send_json_error(['message' => __('Permission denied.', 'wc-licensed-product')]);
}
$orderId = absint($_POST['order_id'] ?? 0);
if (!$orderId) {
wp_send_json_error(['message' => __('Invalid order ID.', 'wc-licensed-product')]);
}
$order = wc_get_order($orderId);
if (!$order) {
wp_send_json_error(['message' => __('Order not found.', 'wc-licensed-product')]);
}
// Check if order is paid
if (!$order->is_paid()) {
wp_send_json_error(['message' => __('Order must be paid before licenses can be generated.', 'wc-licensed-product')]);
}
// 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;
}
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.',
$result['generated'],
'wc-licensed-product'
),
$result['generated']
),
'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' => $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', 'id' => 'wc_licensed_product_default_bind_to_version',
'default' => 'no', 'default' => 'no',
], ],
'enable_multi_domain' => [
'name' => __('Enable Multi-Domain Licensing', 'wc-licensed-product'),
'type' => 'checkbox',
'desc' => __('Allow customers to purchase multiple licenses for different domains at once. Each unit in cart quantity requires a unique domain.', 'wc-licensed-product'),
'id' => 'wc_licensed_product_enable_multi_domain',
'default' => 'no',
],
'section_end' => [ 'section_end' => [
'type' => 'sectionend', 'type' => 'sectionend',
'id' => 'wc_licensed_product_section_defaults_end', 'id' => 'wc_licensed_product_section_defaults_end',
@@ -387,6 +394,14 @@ final class SettingsController
return get_option('wc_licensed_product_default_bind_to_version', 'no') === 'yes'; return get_option('wc_licensed_product_default_bind_to_version', 'no') === 'yes';
} }
/**
* Check if multi-domain licensing is enabled
*/
public static function isMultiDomainEnabled(): bool
{
return get_option('wc_licensed_product_enable_multi_domain', 'no') === 'yes';
}
/** /**
* Check if expiration warning emails are enabled * Check if expiration warning emails are enabled
* This checks both the WooCommerce email setting and the old setting for backwards compatibility * This checks both the WooCommerce email setting and the old setting for backwards compatibility

View File

@@ -147,9 +147,52 @@ final class ResponseSigner
*/ */
private function deriveKey(string $licenseKey): string private function deriveKey(string $licenseKey): string
{ {
// HKDF-like key derivation return self::deriveCustomerSecret($licenseKey, $this->serverSecret);
$prk = hash_hmac('sha256', $licenseKey, $this->serverSecret, true); }
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; namespace Jeremias\WcLicensedProduct\Checkout;
use Automattic\WooCommerce\Blocks\Integrations\IntegrationInterface; use Automattic\WooCommerce\Blocks\Integrations\IntegrationInterface;
use Jeremias\WcLicensedProduct\Admin\SettingsController;
use Jeremias\WcLicensedProduct\Product\LicensedProductVariation;
/** /**
* Integration with WooCommerce Checkout Blocks * Integration with WooCommerce Checkout Blocks
@@ -30,7 +32,7 @@ final class CheckoutBlocksIntegration implements IntegrationInterface
public function initialize(): void public function initialize(): void
{ {
$this->registerScripts(); $this->registerScripts();
$this->registerBlockExtensionData(); $this->registerAdditionalCheckoutFields();
} }
/** /**
@@ -45,7 +47,7 @@ final class CheckoutBlocksIntegration implements IntegrationInterface
wp_register_script( wp_register_script(
'wc-licensed-product-checkout-blocks', 'wc-licensed-product-checkout-blocks',
$scriptUrl, $scriptUrl,
['wc-blocks-checkout', 'wp-element', 'wp-components', 'wp-i18n'], ['wc-blocks-checkout', 'wp-element', 'wp-components', 'wp-i18n', 'wp-plugins', 'wp-data'],
WC_LICENSED_PRODUCT_VERSION, WC_LICENSED_PRODUCT_VERSION,
true true
); );
@@ -59,20 +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_action('woocommerce_blocks_loaded', function (): void {
add_filter( // Check if the function exists (WooCommerce 8.9+)
'woocommerce_blocks_checkout_block_registration_data', if (!function_exists('woocommerce_register_additional_checkout_field')) {
function (array $data): array { return;
$data['wc-licensed-product'] = [
'hasLicensedProducts' => $this->cartHasLicensedProducts(),
];
return $data;
} }
);
// Register the domain field using WooCommerce's checkout fields API
// For single domain mode only (multi-domain uses custom JS component)
if (!SettingsController::isMultiDomainEnabled()) {
woocommerce_register_additional_checkout_field([
'id' => 'wc-licensed-product/domain',
'label' => __('License Domain', 'wc-licensed-product'),
'location' => 'order',
'type' => 'text',
'required' => false,
'attributes' => [
'placeholder' => __('example.com', 'wc-licensed-product'),
'pattern' => '^([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}$',
'title' => __('Enter a valid domain (without http:// or www)', 'wc-licensed-product'),
],
]);
}
});
} }
/** /**
@@ -96,13 +111,23 @@ final class CheckoutBlocksIntegration implements IntegrationInterface
*/ */
public function get_script_data(): array public function get_script_data(): array
{ {
$isMultiDomain = SettingsController::isMultiDomainEnabled();
return [ return [
'hasLicensedProducts' => $this->cartHasLicensedProducts(), 'hasLicensedProducts' => $this->cartHasLicensedProducts(),
'fieldLabel' => __('Domain for License Activation', 'wc-licensed-product'), 'licensedProducts' => $this->getLicensedProductsFromCart(),
'isMultiDomainEnabled' => $isMultiDomain,
'fieldPlaceholder' => __('example.com', 'wc-licensed-product'), 'fieldPlaceholder' => __('example.com', 'wc-licensed-product'),
'fieldDescription' => __('Enter the domain where you will use this license (without http:// or www).', 'wc-licensed-product'), 'fieldDescription' => $isMultiDomain
'sectionTitle' => __('License Domain', 'wc-licensed-product'), ? __('Enter a unique domain for each license (without http:// or www).', 'wc-licensed-product')
'validationError' => __('Please enter a valid domain for your license activation.', 'wc-licensed-product'), : __('Enter the domain where you will use the license (without http:// or www).', 'wc-licensed-product'),
'sectionTitle' => $isMultiDomain
? __('License Domains', 'wc-licensed-product')
: __('License Domain', 'wc-licensed-product'),
'validationError' => __('Please enter a valid domain.', 'wc-licensed-product'),
'duplicateError' => __('Each license requires a unique domain.', 'wc-licensed-product'),
'licenseLabel' => __('License %d:', 'wc-licensed-product'),
'singleDomainLabel' => __('Domain', 'wc-licensed-product'),
]; ];
} }
@@ -111,17 +136,69 @@ final class CheckoutBlocksIntegration implements IntegrationInterface
*/ */
private function cartHasLicensedProducts(): bool private function cartHasLicensedProducts(): bool
{ {
if (!WC()->cart) { return !empty($this->getLicensedProductsFromCart());
return false;
} }
/**
* 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) { foreach (WC()->cart->get_cart() as $cartItem) {
$product = $cartItem['data']; $product = $cartItem['data'];
if ($product && $product->is_type('licensed')) { if (!$product) {
return true; 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; namespace Jeremias\WcLicensedProduct\Checkout;
use Jeremias\WcLicensedProduct\License\LicenseManager; use Jeremias\WcLicensedProduct\License\LicenseManager;
use Jeremias\WcLicensedProduct\Admin\SettingsController;
use Jeremias\WcLicensedProduct\Product\LicensedProductVariation;
/** /**
* Handles checkout modifications for licensed products * Handles checkout modifications for licensed products
@@ -50,35 +52,113 @@ final class CheckoutController
*/ */
private function cartHasLicensedProducts(): bool private function cartHasLicensedProducts(): bool
{ {
if (!WC()->cart) { return !empty($this->getLicensedProductsFromCart());
return false;
}
foreach (WC()->cart->get_cart() as $cartItem) {
$product = $cartItem['data'];
if ($product && $product->is_type('licensed')) {
return true;
}
}
return false;
} }
/** /**
* Add domain field to checkout form * Get licensed products from cart with quantities
*
* @return array<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 public function addDomainField(): void
{ {
if (!$this->cartHasLicensedProducts()) { $licensedProducts = $this->getLicensedProductsFromCart();
if (empty($licensedProducts)) {
return; return;
} }
// Check if multi-domain licensing is enabled
if (SettingsController::isMultiDomainEnabled()) {
$this->renderMultiDomainFields($licensedProducts);
} else {
$this->renderSingleDomainField();
}
}
/**
* Render single domain field (legacy mode)
*/
private function renderSingleDomainField(): void
{
$savedValue = '';
// Check POST data first (validation failure case)
if (isset($_POST['licensed_product_domain'])) {
$savedValue = sanitize_text_field($_POST['licensed_product_domain']);
} elseif (WC()->session) {
$savedValue = WC()->session->get('licensed_product_domain', '');
}
?> ?>
<div id="licensed-product-domain-field"> <div id="licensed-product-domain-field">
<h3><?php esc_html_e('License Domain', 'wc-licensed-product'); ?></h3> <h3><?php esc_html_e('License Domain', 'wc-licensed-product'); ?></h3>
<p class="form-row form-row-wide"> <p class="form-row form-row-wide">
<label for="licensed_product_domain"> <label for="licensed_product_domain">
<?php esc_html_e('Domain for License Activation', 'wc-licensed-product'); ?> <?php esc_html_e('Domain', 'wc-licensed-product'); ?>
<abbr class="required" title="<?php esc_attr_e('required', 'wc-licensed-product'); ?>">*</abbr> <abbr class="required" title="<?php esc_attr_e('required', 'wc-licensed-product'); ?>">*</abbr>
</label> </label>
<input <input
@@ -87,10 +167,10 @@ final class CheckoutController
name="licensed_product_domain" name="licensed_product_domain"
id="licensed_product_domain" id="licensed_product_domain"
placeholder="<?php esc_attr_e('example.com', 'wc-licensed-product'); ?>" placeholder="<?php esc_attr_e('example.com', 'wc-licensed-product'); ?>"
value="<?php echo esc_attr(WC()->checkout->get_value('licensed_product_domain')); ?>" value="<?php echo esc_attr($savedValue); ?>"
/> />
<span class="description"> <span class="description">
<?php esc_html_e('Enter the domain where you will use this license (without http:// or www).', 'wc-licensed-product'); ?> <?php esc_html_e('Enter the domain where you will use the license (without http:// or www).', 'wc-licensed-product'); ?>
</span> </span>
</p> </p>
</div> </div>
@@ -98,62 +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 public function validateDomainField(): void
{ {
if (!$this->cartHasLicensedProducts()) { $licensedProducts = $this->getLicensedProductsFromCart();
if (empty($licensedProducts)) {
return; return;
} }
$domain = isset($_POST['licensed_product_domain']) // Check if multi-domain licensing is enabled
? sanitize_text_field($_POST['licensed_product_domain']) if (SettingsController::isMultiDomainEnabled()) {
: ''; $this->validateMultiDomainFields($licensedProducts);
} else {
$this->validateSingleDomainField();
}
}
/**
* Validate single domain field
*/
private function validateSingleDomainField(): void
{
$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;
}
$normalizedDomain = $this->licenseManager->normalizeDomain($domain);
if (!$this->isValidDomain($normalizedDomain)) {
wc_add_notice(__('Please enter a valid domain for your license.', 'wc-licensed-product'), 'error');
}
}
/**
* 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)) { if (empty($domain)) {
wc_add_notice( wc_add_notice(
__('Please enter a domain for your license activation.', 'wc-licensed-product'), 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' 'error'
); );
return; continue;
} }
// Validate domain format // Validate domain format
$normalizedDomain = $this->licenseManager->normalizeDomain($domain); $normalizedDomain = $this->licenseManager->normalizeDomain($domain);
if (!$this->isValidDomain($normalizedDomain)) { if (!$this->isValidDomain($normalizedDomain)) {
wc_add_notice( wc_add_notice(
__('Please enter a valid domain name.', 'wc-licensed-product'), 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' '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;
}
}
} }
} }
/** /**
* Save domain field to order meta * Save domain fields to order meta
*/ */
public function saveDomainField(int $orderId): void public function saveDomainField(int $orderId): void
{ {
if (!$this->cartHasLicensedProducts()) { $licensedProducts = $this->getLicensedProductsFromCart();
if (empty($licensedProducts)) {
return; 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);
$order = wc_get_order($orderId); $order = wc_get_order($orderId);
if ($order) { 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->update_meta_data('_licensed_product_domain', $normalizedDomain);
$order->save(); $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 domain in admin order view * Display domains in admin order view
*/ */
public function displayDomainInAdmin(\WC_Order $order): void public function displayDomainInAdmin(\WC_Order $order): void
{ {
// Try new multi-domain format first
$domainData = $order->get_meta('_licensed_product_domains');
if (!empty($domainData) && is_array($domainData)) {
$this->displayMultiDomainsInAdmin($domainData);
return;
}
// Fall back to legacy single domain
$domain = $order->get_meta('_licensed_product_domain'); $domain = $order->get_meta('_licensed_product_domain');
if (!$domain) { if (!$domain) {
return; return;
@@ -168,10 +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 public function displayDomainInEmail(\WC_Order $order, bool $sentToAdmin, bool $plainText): void
{ {
// Try new multi-domain format first
$domainData = $order->get_meta('_licensed_product_domains');
if (!empty($domainData) && is_array($domainData)) {
$this->displayMultiDomainsInEmail($domainData, $plainText);
return;
}
// Fall back to legacy single domain
$domain = $order->get_meta('_licensed_product_domain'); $domain = $order->get_meta('_licensed_product_domain');
if (!$domain) { if (!$domain) {
return; return;
@@ -189,6 +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 * Validate domain format
*/ */

View File

@@ -12,6 +12,7 @@ namespace Jeremias\WcLicensedProduct\Checkout;
use Automattic\WooCommerce\StoreApi\Schemas\V1\CheckoutSchema; use Automattic\WooCommerce\StoreApi\Schemas\V1\CheckoutSchema;
use Automattic\WooCommerce\StoreApi\StoreApi; use Automattic\WooCommerce\StoreApi\StoreApi;
use Automattic\WooCommerce\StoreApi\Schemas\ExtendSchema; use Automattic\WooCommerce\StoreApi\Schemas\ExtendSchema;
use Jeremias\WcLicensedProduct\Admin\SettingsController;
use Jeremias\WcLicensedProduct\License\LicenseManager; use Jeremias\WcLicensedProduct\License\LicenseManager;
/** /**
@@ -70,6 +71,12 @@ final class StoreApiExtension
*/ */
public function getExtensionData(): array public function getExtensionData(): array
{ {
if (SettingsController::isMultiDomainEnabled()) {
return [
'licensed_product_domains' => WC()->session ? WC()->session->get('licensed_product_domains', []) : [],
];
}
return [ return [
'licensed_product_domain' => WC()->session ? WC()->session->get('licensed_product_domain', '') : '', 'licensed_product_domain' => WC()->session ? WC()->session->get('licensed_product_domain', '') : '',
]; ];
@@ -80,6 +87,34 @@ final class StoreApiExtension
*/ */
public function getExtensionSchema(): array public function getExtensionSchema(): array
{ {
if (SettingsController::isMultiDomainEnabled()) {
return [
'licensed_product_domains' => [
'description' => __('Domains for license activation by product', 'wc-licensed-product'),
'type' => 'array',
'context' => ['view', 'edit'],
'readonly' => false,
'items' => [
'type' => 'object',
'properties' => [
'product_id' => [
'type' => 'integer',
],
'variation_id' => [
'type' => 'integer',
],
'domains' => [
'type' => 'array',
'items' => [
'type' => 'string',
],
],
],
],
],
];
}
return [ return [
'licensed_product_domain' => [ 'licensed_product_domain' => [
'description' => __('Domain for license activation', 'wc-licensed-product'), 'description' => __('Domain for license activation', 'wc-licensed-product'),
@@ -95,32 +130,113 @@ final class StoreApiExtension
*/ */
public function handleExtensionUpdate(array $data): void public function handleExtensionUpdate(array $data): void
{ {
if (isset($data['licensed_product_domain'])) { if (SettingsController::isMultiDomainEnabled()) {
$domain = sanitize_text_field($data['licensed_product_domain']); // Multi-domain mode
$normalizedDomain = $this->licenseManager->normalizeDomain($domain); if (isset($data['licensed_product_domains']) && is_array($data['licensed_product_domains'])) {
$normalizedData = $this->normalizeDomainsData($data['licensed_product_domains']);
if (WC()->session) { if (WC()->session) {
WC()->session->set('licensed_product_domain', $normalizedDomain); WC()->session->set('licensed_product_domains', $normalizedData);
}
}
} else {
// Single domain mode
if (isset($data['licensed_product_domain'])) {
$sanitized = sanitize_text_field($data['licensed_product_domain']);
$normalized = $this->licenseManager->normalizeDomain($sanitized);
if (WC()->session) {
WC()->session->set('licensed_product_domain', $normalized);
}
} }
} }
} }
/** /**
* Process the checkout order - save domain to order meta * Normalize domains data from frontend
*/
private function normalizeDomainsData(array $domainsData): array
{
$normalized = [];
foreach ($domainsData as $item) {
if (!isset($item['product_id']) || !isset($item['domains']) || !is_array($item['domains'])) {
continue;
}
$productId = (int) $item['product_id'];
$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 public function processCheckoutOrder(\WC_Order $order): void
{ {
$domain = WC()->session ? WC()->session->get('licensed_product_domain', '') : '';
// Also check in the request data for block checkout
if (empty($domain)) {
$requestData = json_decode(file_get_contents('php://input'), true); $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']); if (SettingsController::isMultiDomainEnabled()) {
$domain = $this->licenseManager->normalizeDomain($domain); $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)) { if (!empty($domain)) {
$order->update_meta_data('_licensed_product_domain', $domain); $order->update_meta_data('_licensed_product_domain', $domain);
$order->save(); $order->save();
@@ -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 public function addLicenseToOrderItem(int $itemId, \WC_Order_Item $item, \WC_Order $order, bool $plainText): void
{ {
@@ -203,85 +203,106 @@ final class LicenseEmailController
return; return;
} }
$license = $this->licenseManager->getLicenseByOrderAndProduct($order->get_id(), $product->get_id()); $licenses = $this->licenseManager->getLicensesByOrderAndProduct($order->get_id(), $product->get_id());
if (!$license) { if (empty($licenses)) {
return; return;
} }
if ($plainText) { if ($plainText) {
echo "\n" . esc_html__('License Key:', 'wc-licensed-product') . ' ' . esc_html($license->getLicenseKey()) . "\n"; echo "\n" . esc_html__('License Keys:', 'wc-licensed-product') . "\n";
foreach ($licenses as $license) {
echo ' - ' . esc_html($license->getLicenseKey());
echo ' (' . esc_html($license->getDomain()) . ')' . "\n";
}
} else { } else {
?> ?>
<div style="margin-top: 10px; padding: 10px; background-color: #f8f9fa; border-left: 3px solid #7f54b3;"> <div style="margin-top: 10px; padding: 10px; background-color: #f8f9fa; border-left: 3px solid #7f54b3;">
<strong><?php esc_html_e('License Key:', 'wc-licensed-product'); ?></strong> <strong><?php esc_html_e('License Keys:', 'wc-licensed-product'); ?></strong>
<code style="display: block; margin-top: 5px; padding: 5px; background: #fff; font-family: monospace;"> <?php foreach ($licenses as $license) : ?>
<div style="margin-top: 5px; padding: 5px; background: #fff;">
<code style="font-family: monospace;">
<?php echo esc_html($license->getLicenseKey()); ?> <?php echo esc_html($license->getLicenseKey()); ?>
</code> </code>
<span style="color: #666; margin-left: 10px;">
<?php echo esc_html($license->getDomain()); ?>
</span>
</div>
<?php endforeach; ?>
</div> </div>
<?php <?php
} }
} }
/** /**
* Get all licenses for an order * Get all licenses for an order grouped by product
*
* @return array Array of products with their licenses
*/ */
private function getLicensesForOrder(\WC_Order $order): array private function getLicensesForOrder(\WC_Order $order): array
{ {
$licenses = []; $products = [];
foreach ($order->get_items() as $item) { foreach ($order->get_items() as $item) {
$product = $item->get_product(); $product = $item->get_product();
if ($product && $product->is_type('licensed')) { if ($product && $product->is_type('licensed')) {
$license = $this->licenseManager->getLicenseByOrderAndProduct($order->get_id(), $product->get_id()); $licenses = $this->licenseManager->getLicensesByOrderAndProduct($order->get_id(), $product->get_id());
if ($license) { if (!empty($licenses)) {
$licenses[] = [ $products[] = [
'license' => $license,
'product_name' => $product->get_name(), 'product_name' => $product->get_name(),
'licenses' => $licenses,
]; ];
} }
} }
} }
return $licenses; return $products;
} }
/** /**
* Render license info in HTML format * Render license info in HTML format
*/ */
private function renderHtmlLicenseInfo(array $licenses, \WC_Order $order): void private function renderHtmlLicenseInfo(array $products, \WC_Order $order): void
{ {
$domain = $order->get_meta('_licensed_product_domain');
?> ?>
<div style="margin: 20px 0; padding: 20px; background-color: #f8f9fa; border: 1px solid #e5e5e5; border-radius: 4px;"> <div style="margin: 20px 0; padding: 20px; background-color: #f8f9fa; border: 1px solid #e5e5e5; border-radius: 4px;">
<h2 style="margin-top: 0; color: #333;"><?php esc_html_e('Your License Keys', 'wc-licensed-product'); ?></h2> <h2 style="margin-top: 0; color: #333;"><?php esc_html_e('Your License Keys', 'wc-licensed-product'); ?></h2>
<?php if ($domain) : ?> <?php foreach ($products as $product) : ?>
<p style="margin-bottom: 15px;"> <div style="margin-bottom: 20px;">
<strong><?php esc_html_e('Licensed Domain:', 'wc-licensed-product'); ?></strong> <h3 style="margin: 0 0 10px 0; font-size: 1.1em; color: #333;">
<?php echo esc_html($domain); ?> <?php echo esc_html($product['product_name']); ?>
</p> <span style="font-weight: normal; color: #666; font-size: 0.9em;">
<?php endif; ?> (<?php
printf(
esc_html(_n('%d license', '%d licenses', count($product['licenses']), 'wc-licensed-product')),
count($product['licenses'])
);
?>)
</span>
</h3>
<table style="width: 100%; border-collapse: collapse;"> <table style="width: 100%; border-collapse: collapse; background: #fff;">
<thead> <thead>
<tr> <tr>
<th style="text-align: left; padding: 10px; border-bottom: 2px solid #ddd;"><?php esc_html_e('Product', 'wc-licensed-product'); ?></th> <th style="text-align: left; padding: 8px 10px; border-bottom: 2px solid #ddd; font-size: 0.9em;"><?php esc_html_e('License Key', 'wc-licensed-product'); ?></th>
<th style="text-align: left; padding: 10px; border-bottom: 2px solid #ddd;"><?php esc_html_e('License Key', 'wc-licensed-product'); ?></th> <th style="text-align: left; padding: 8px 10px; border-bottom: 2px solid #ddd; font-size: 0.9em;"><?php esc_html_e('Domain', 'wc-licensed-product'); ?></th>
<th style="text-align: left; padding: 10px; border-bottom: 2px solid #ddd;"><?php esc_html_e('Expires', 'wc-licensed-product'); ?></th> <th style="text-align: left; padding: 8px 10px; border-bottom: 2px solid #ddd; font-size: 0.9em;"><?php esc_html_e('Expires', 'wc-licensed-product'); ?></th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<?php foreach ($licenses as $item) : ?> <?php foreach ($product['licenses'] as $license) : ?>
<tr> <tr>
<td style="padding: 10px; border-bottom: 1px solid #eee;"><?php echo esc_html($item['product_name']); ?></td> <td style="padding: 8px 10px; border-bottom: 1px solid #eee;">
<td style="padding: 10px; border-bottom: 1px solid #eee;"> <code style="background: #f5f5f5; padding: 3px 6px; font-family: monospace; font-size: 0.9em;">
<code style="background: #fff; padding: 3px 6px; font-family: monospace;"> <?php echo esc_html($license->getLicenseKey()); ?>
<?php echo esc_html($item['license']->getLicenseKey()); ?>
</code> </code>
</td> </td>
<td style="padding: 10px; border-bottom: 1px solid #eee;"> <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 <?php
$expiresAt = $item['license']->getExpiresAt(); $expiresAt = $license->getExpiresAt();
echo $expiresAt echo $expiresAt
? esc_html($expiresAt->format(get_option('date_format'))) ? esc_html($expiresAt->format(get_option('date_format')))
: esc_html__('Never', 'wc-licensed-product'); : esc_html__('Never', 'wc-licensed-product');
@@ -291,6 +312,8 @@ final class LicenseEmailController
<?php endforeach; ?> <?php endforeach; ?>
</tbody> </tbody>
</table> </table>
</div>
<?php endforeach; ?>
<p style="margin-top: 15px; margin-bottom: 0; font-size: 0.9em; color: #666;"> <p style="margin-top: 15px; margin-bottom: 0; font-size: 0.9em; color: #666;">
<?php esc_html_e('You can also view your licenses in your account under "Licenses".', 'wc-licensed-product'); ?> <?php esc_html_e('You can also view your licenses in your account under "Licenses".', 'wc-licensed-product'); ?>
@@ -302,30 +325,34 @@ final class LicenseEmailController
/** /**
* Render license info in plain text format * Render license info in plain text format
*/ */
private function renderPlainTextLicenseInfo(array $licenses, \WC_Order $order): void private function renderPlainTextLicenseInfo(array $products, \WC_Order $order): void
{ {
$domain = $order->get_meta('_licensed_product_domain');
echo "\n\n"; echo "\n\n";
echo "==========================================================\n"; echo "==========================================================\n";
echo esc_html__('YOUR LICENSE KEYS', 'wc-licensed-product') . "\n"; echo esc_html__('YOUR LICENSE KEYS', 'wc-licensed-product') . "\n";
echo "==========================================================\n\n"; echo "==========================================================\n\n";
if ($domain) { foreach ($products as $product) {
echo esc_html__('Licensed Domain:', 'wc-licensed-product') . ' ' . esc_html($domain) . "\n\n"; echo esc_html($product['product_name']);
} echo ' (' . count($product['licenses']) . ' ' .
_n('license', 'licenses', count($product['licenses']), 'wc-licensed-product') . ')';
echo "\n";
echo "-----------------------------------------------------------\n";
foreach ($licenses as $item) { foreach ($product['licenses'] as $license) {
echo esc_html($item['product_name']) . "\n"; echo esc_html__('License Key:', 'wc-licensed-product') . ' ';
echo esc_html__('License Key:', 'wc-licensed-product') . ' ' . esc_html($item['license']->getLicenseKey()) . "\n"; echo esc_html($license->getLicenseKey()) . "\n";
echo esc_html__('Domain:', 'wc-licensed-product') . ' ';
$expiresAt = $item['license']->getExpiresAt(); echo esc_html($license->getDomain()) . "\n";
echo esc_html__('Expires:', 'wc-licensed-product') . ' '; echo esc_html__('Expires:', 'wc-licensed-product') . ' ';
$expiresAt = $license->getExpiresAt();
echo $expiresAt echo $expiresAt
? esc_html($expiresAt->format(get_option('date_format'))) ? esc_html($expiresAt->format(get_option('date_format')))
: esc_html__('Never', 'wc-licensed-product'); : esc_html__('Never', 'wc-licensed-product');
echo "\n\n"; echo "\n\n";
} }
}
echo esc_html__('You can also view your licenses in your account under "Licenses".', 'wc-licensed-product') . "\n"; echo esc_html__('You can also view your licenses in your account under "Licenses".', 'wc-licensed-product') . "\n";
echo "==========================================================\n\n"; echo "==========================================================\n\n";

View File

@@ -9,6 +9,7 @@ declare(strict_types=1);
namespace Jeremias\WcLicensedProduct\Frontend; namespace Jeremias\WcLicensedProduct\Frontend;
use Jeremias\WcLicensedProduct\Api\ResponseSigner;
use Jeremias\WcLicensedProduct\License\LicenseManager; use Jeremias\WcLicensedProduct\License\LicenseManager;
use Jeremias\WcLicensedProduct\Product\VersionManager; use Jeremias\WcLicensedProduct\Product\VersionManager;
use Twig\Environment; use Twig\Environment;
@@ -107,16 +108,92 @@ final class AccountController
$licenses = $this->licenseManager->getLicensesByCustomer($customerId); $licenses = $this->licenseManager->getLicensesByCustomer($customerId);
// Enrich licenses with product data and downloads // Group licenses by product+order into "packages"
$enrichedLicenses = []; $packages = $this->groupLicensesIntoPackages($licenses);
foreach ($licenses as $license) {
$product = wc_get_product($license->getProductId());
$order = wc_get_order($license->getOrderId());
// Get available downloads for this license try {
$downloads = []; echo $this->twig->render('frontend/licenses.html.twig', [
'packages' => $packages,
'has_packages' => !empty($packages),
'signing_enabled' => ResponseSigner::isSigningEnabled(),
]);
} catch (\Exception $e) {
// Fallback to PHP template if Twig fails
$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') { if ($license->getStatus() === 'active') {
$versions = $this->versionManager->getVersionsByProduct($license->getProductId()); $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) { foreach ($versions as $version) {
if ($version->isActive() && ($version->getAttachmentId() || $version->getDownloadUrl())) { if ($version->isActive() && ($version->getAttachmentId() || $version->getDownloadUrl())) {
$downloads[] = [ $downloads[] = [
@@ -124,7 +201,7 @@ final class AccountController
'version_id' => $version->getId(), 'version_id' => $version->getId(),
'filename' => $version->getDownloadFilename(), 'filename' => $version->getDownloadFilename(),
'download_url' => $this->downloadController->generateDownloadUrl( 'download_url' => $this->downloadController->generateDownloadUrl(
$license->getId(), $licenseId,
$version->getId() $version->getId()
), ),
'release_notes' => $version->getReleaseNotes(), 'release_notes' => $version->getReleaseNotes(),
@@ -133,112 +210,151 @@ final class AccountController
]; ];
} }
} }
}
$enrichedLicenses[] = [ return $downloads;
'license' => $license,
'product_name' => $product ? $product->get_name() : __('Unknown Product', 'wc-licensed-product'),
'product_url' => $product ? $product->get_permalink() : '',
'order_number' => $order ? $order->get_order_number() : '',
'order_url' => $order ? $order->get_view_order_url() : '',
'downloads' => $downloads,
];
}
try {
echo $this->twig->render('frontend/licenses.html.twig', [
'licenses' => $enrichedLicenses,
'has_licenses' => !empty($enrichedLicenses),
]);
} catch (\Exception $e) {
// Fallback to PHP template if Twig fails
$this->displayLicensesFallback($enrichedLicenses);
}
} }
/** /**
* Fallback display method if Twig is unavailable * Fallback display method if Twig is unavailable
*/ */
private function displayLicensesFallback(array $enrichedLicenses): void private function displayLicensesFallback(array $packages): void
{ {
if (empty($enrichedLicenses)) { if (empty($packages)) {
echo '<p>' . esc_html__('You have no licenses yet.', 'wc-licensed-product') . '</p>'; echo '<p>' . esc_html__('You have no licenses yet.', 'wc-licensed-product') . '</p>';
return; return;
} }
?> ?>
<div class="woocommerce-licenses"> <div class="woocommerce-licenses">
<?php foreach ($enrichedLicenses as $item): ?> <?php foreach ($packages as $package): ?>
<div class="license-card"> <div class="license-package">
<div class="license-header"> <div class="package-header">
<h3> <h3>
<?php if ($item['product_url']): ?> <?php if ($package['product_url']): ?>
<a href="<?php echo esc_url($item['product_url']); ?>"> <a href="<?php echo esc_url($package['product_url']); ?>">
<?php echo esc_html($item['product_name']); ?> <?php echo esc_html($package['product_name']); ?>
</a> </a>
<?php else: ?> <?php else: ?>
<?php echo esc_html($item['product_name']); ?> <?php echo esc_html($package['product_name']); ?>
<?php endif; ?> <?php endif; ?>
</h3> </h3>
<span class="license-status license-status-<?php echo esc_attr($item['license']->getStatus()); ?>"> <span class="package-order">
<?php echo esc_html(ucfirst($item['license']->getStatus())); ?> <?php
printf(
/* translators: %s: order number */
esc_html__('Order #%s', 'wc-licensed-product'),
esc_html($package['order_number'])
);
?>
</span> </span>
</div> </div>
<div class="license-details"> <div class="package-licenses">
<div class="license-key-row"> <?php foreach ($package['licenses'] as $license): ?>
<label><?php esc_html_e('License Key:', 'wc-licensed-product'); ?></label> <div class="license-entry license-entry-<?php echo esc_attr($license['status']); ?>">
<code class="license-key" data-license-key="<?php echo esc_attr($item['license']->getLicenseKey()); ?>"> <div class="license-row-primary">
<?php echo esc_html($item['license']->getLicenseKey()); ?> <div class="license-key-group">
</code> <code class="license-key"><?php echo esc_html($license['license_key']); ?></code>
<button type="button" class="copy-license-btn" data-license-key="<?php echo esc_attr($item['license']->getLicenseKey()); ?>" title="<?php esc_attr_e('Copy to clipboard', 'wc-licensed-product'); ?>"> <span class="license-status license-status-<?php echo esc_attr($license['status']); ?>">
<?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> <span class="dashicons dashicons-clipboard"></span>
</button> </button>
</div> <?php if ($license['is_transferable']): ?>
<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" <button type="button" class="wclp-transfer-btn"
data-license-id="<?php echo esc_attr($item['license']->getId()); ?>" data-license-id="<?php echo esc_attr($license['id']); ?>"
data-current-domain="<?php echo esc_attr($item['license']->getDomain()); ?>" data-current-domain="<?php echo esc_attr($license['domain']); ?>"
title="<?php esc_attr_e('Transfer to new domain', 'wc-licensed-product'); ?>"> title="<?php esc_attr_e('Transfer to new domain', 'wc-licensed-product'); ?>">
<span class="dashicons dashicons-randomize"></span> <span class="dashicons dashicons-randomize"></span>
<?php esc_html_e('Transfer', 'wc-licensed-product'); ?>
</button> </button>
<?php endif; ?> <?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>
<span><strong><?php esc_html_e('Expires:', 'wc-licensed-product'); ?></strong> <span class="license-meta-item license-expiry">
<span class="dashicons dashicons-calendar-alt"></span>
<?php <?php
$expiresAt = $item['license']->getExpiresAt(); echo $license['expires_at']
echo $expiresAt ? esc_html($license['expires_at']->format('Y-m-d'))
? esc_html($expiresAt->format(get_option('date_format'))) : '<span class="lifetime">' . esc_html__('Lifetime', 'wc-licensed-product') . '</span>';
: esc_html__('Never', 'wc-licensed-product');
?> ?>
</span> </span>
</div> </div>
</div> </div>
<?php endforeach; ?>
</div>
<?php if (!empty($item['downloads'])): ?> <?php if (!empty($package['downloads'])): ?>
<div class="license-downloads"> <div class="package-downloads">
<h4><?php esc_html_e('Available Downloads', 'wc-licensed-product'); ?></h4> <h4><?php esc_html_e('Available Downloads', 'wc-licensed-product'); ?></h4>
<ul class="download-list"> <ul class="download-list">
<?php foreach ($item['downloads'] as $download): ?> <?php
<li> $latest = $package['downloads'][0];
?>
<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($latest['filename'] ?: sprintf(__('Version %s', 'wc-licensed-product'), $latest['version'])); ?>
</a>
<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"> <a href="<?php echo esc_url($download['download_url']); ?>" class="download-link">
<span class="dashicons dashicons-download"></span> <span class="dashicons dashicons-download"></span>
<?php echo esc_html($download['filename'] ?: sprintf(__('Version %s', 'wc-licensed-product'), $download['version'])); ?> <?php echo esc_html($download['filename'] ?: sprintf(__('Version %s', 'wc-licensed-product'), $download['version'])); ?>
</a> </a>
<span class="download-version">v<?php echo esc_html($download['version']); ?></span> </div>
<div class="download-row-meta">
<span class="download-date"><?php echo esc_html($download['released_at']); ?></span> <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> </li>
<?php endforeach; ?> <?php endforeach; ?>
</ul> </ul>
</div> </div>
<?php endif; ?> <?php endif; ?>
</div> </div>
<?php endif; ?>
</div>
<?php endforeach; ?> <?php endforeach; ?>
</div> </div>

View File

@@ -11,12 +11,43 @@ namespace Jeremias\WcLicensedProduct\License;
use Jeremias\WcLicensedProduct\Installer; use Jeremias\WcLicensedProduct\Installer;
use Jeremias\WcLicensedProduct\Product\LicensedProduct; use Jeremias\WcLicensedProduct\Product\LicensedProduct;
use Jeremias\WcLicensedProduct\Product\LicensedProductVariation;
use Jeremias\WcLicensedProduct\Product\LicensedVariableProduct;
/** /**
* Manages license operations (CRUD, validation, generation) * Manages license operations (CRUD, validation, generation)
*/ */
class LicenseManager 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 * Generate a unique license key
*/ */
@@ -40,29 +71,63 @@ class LicenseManager
/** /**
* Generate a license for a completed order * 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( public function generateLicense(
int $orderId, int $orderId,
int $productId, int $productId,
int $customerId, int $customerId,
string $domain string $domain,
?int $variationId = null
): ?License { ): ?License {
global $wpdb; global $wpdb;
// Check if license already exists for this order and product // Normalize domain first for duplicate detection
$existing = $this->getLicenseByOrderAndProduct($orderId, $productId); $normalizedDomain = $this->normalizeDomain($domain);
// Check if license already exists for this order, product, domain, and variation
$existing = $this->getLicenseByOrderProductDomainAndVariation($orderId, $productId, $normalizedDomain, $variationId);
if ($existing) { if ($existing) {
return $existing; return $existing;
} }
$product = wc_get_product($productId); // Load the product that has the license settings
if (!$product || !$product->is_type('licensed')) { // 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; return null;
} }
// Ensure we have the LicensedProduct instance for type hints // Ensure we have the LicensedProduct instance for type hints
if (!$product instanceof LicensedProduct) { if (!$settingsProduct instanceof LicensedProduct) {
$product = new LicensedProduct($productId); $settingsProduct = new LicensedProduct($productId);
}
}
if (!$settingsProduct) {
return null;
} }
// Generate unique license key // Generate unique license key
@@ -71,16 +136,16 @@ class LicenseManager
$licenseKey = $this->generateLicenseKey(); $licenseKey = $this->generateLicenseKey();
} }
// Calculate expiration date // Calculate expiration date from the settings product (variation or parent)
$expiresAt = null; $expiresAt = null;
$validityDays = $product->get_validity_days(); $validityDays = $settingsProduct->get_validity_days();
if ($validityDays !== null && $validityDays > 0) { if ($validityDays !== null && $validityDays > 0) {
$expiresAt = (new \DateTimeImmutable())->modify("+{$validityDays} days")->format('Y-m-d H:i:s'); $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; $versionId = null;
if ($product->is_bound_to_version()) { if ($settingsProduct->is_bound_to_version()) {
$versionId = $this->getCurrentVersionId($productId); $versionId = $this->getCurrentVersionId($productId);
} }
@@ -96,7 +161,7 @@ class LicenseManager
'version_id' => $versionId, 'version_id' => $versionId,
'status' => License::STATUS_ACTIVE, 'status' => License::STATUS_ACTIVE,
'activations_count' => 1, 'activations_count' => 1,
'max_activations' => $product->get_max_activations(), 'max_activations' => $settingsProduct->get_max_activations(),
'expires_at' => $expiresAt, 'expires_at' => $expiresAt,
], ],
['%s', '%d', '%d', '%d', '%s', '%d', '%s', '%d', '%d', '%s'] ['%s', '%d', '%d', '%d', '%s', '%d', '%s', '%d', '%d', '%s']
@@ -109,6 +174,16 @@ class LicenseManager
return $this->getLicenseById((int) $wpdb->insert_id); 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 * Get license by ID
*/ */
@@ -161,6 +236,49 @@ class LicenseManager
return $row ? License::fromArray($row) : null; return $row ? License::fromArray($row) : null;
} }
/**
* Get all licenses for an order and product
*
* @return License[]
*/
public function getLicensesByOrderAndProduct(int $orderId, int $productId): array
{
global $wpdb;
$tableName = Installer::getLicensesTable();
$rows = $wpdb->get_results(
$wpdb->prepare(
"SELECT * FROM {$tableName} WHERE order_id = %d AND product_id = %d ORDER BY created_at ASC",
$orderId,
$productId
),
ARRAY_A
);
return array_map(fn(array $row) => License::fromArray($row), $rows ?: []);
}
/**
* Get license by order, product, and domain
*/
public function getLicenseByOrderProductAndDomain(int $orderId, int $productId, string $domain): ?License
{
global $wpdb;
$tableName = Installer::getLicensesTable();
$row = $wpdb->get_row(
$wpdb->prepare(
"SELECT * FROM {$tableName} WHERE order_id = %d AND product_id = %d AND domain = %s",
$orderId,
$productId,
$domain
),
ARRAY_A
);
return $row ? License::fromArray($row) : null;
}
/** /**
* Get all licenses for an order * Get all licenses for an order
*/ */

View File

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

View File

@@ -208,22 +208,98 @@ final class Plugin
return; return;
} }
// Try new multi-domain format first
$domainData = $order->get_meta('_licensed_product_domains');
if (!empty($domainData) && is_array($domainData)) {
$this->generateLicensesMultiDomain($order, $domainData);
return;
}
// Fall back to legacy single domain format
$this->generateLicensesSingleDomain($order);
}
/**
* Generate licenses for new multi-domain format
*/
private function generateLicensesMultiDomain(\WC_Order $order, array $domainData): void
{
$orderId = $order->get_id();
$customerId = $order->get_customer_id();
// Index domains by product ID (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) { foreach ($order->get_items() as $item) {
$product = $item->get_product(); $product = $item->get_product();
if ($product && $product->is_type('licensed')) { if (!$product || !$this->licenseManager->isLicensedProduct($product)) {
$domain = $order->get_meta('_licensed_product_domain'); continue;
if ($domain) { }
// 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( $this->licenseManager->generateLicense(
$orderId, $orderId,
$product->get_id(), $productId,
$order->get_customer_id(), $customerId,
$domain $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 * Get Twig environment
*/ */

View File

@@ -12,7 +12,8 @@ namespace Jeremias\WcLicensedProduct\Product;
use Jeremias\WcLicensedProduct\Admin\SettingsController; 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 final class LicensedProductType
{ {
@@ -29,7 +30,7 @@ final class LicensedProductType
*/ */
private function registerHooks(): void private function registerHooks(): void
{ {
// Register product type // Register product types
add_filter('product_type_selector', [$this, 'addProductType']); add_filter('product_type_selector', [$this, 'addProductType']);
add_filter('woocommerce_product_class', [$this, 'getProductClass'], 10, 2); add_filter('woocommerce_product_class', [$this, 'getProductClass'], 10, 2);
@@ -39,9 +40,11 @@ final class LicensedProductType
// Save product meta // Save product meta
add_action('woocommerce_process_product_meta_licensed', [$this, 'saveProductMeta']); 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 // Show price and add to cart for licensed products
add_action('woocommerce_licensed_add_to_cart', [$this, 'addToCartTemplate']); add_action('woocommerce_licensed_add_to_cart', [$this, 'addToCartTemplate']);
add_action('woocommerce_licensed-variable_add_to_cart', [$this, 'variableAddToCartTemplate']);
// Make product virtual by default // Make product virtual by default
add_filter('woocommerce_product_is_virtual', [$this, 'isVirtual'], 10, 2); 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 // Enqueue frontend CSS for licensed products on single product pages
add_action('wp_enqueue_scripts', [$this, 'enqueueFrontendStyles']); 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 public function addProductType(array $types): array
{ {
$types['licensed'] = __('Licensed Product', 'wc-licensed-product'); $types['licensed'] = __('Licensed Product', 'wc-licensed-product');
$types['licensed-variable'] = __('Licensed Variable Product', 'wc-licensed-product');
return $types; return $types;
} }
/** /**
* Get product class for licensed type * Get product class for licensed types
*/ */
public function getProductClass(string $className, string $productType): string public function getProductClass(string $className, string $productType): string
{ {
if ($productType === 'licensed') { if ($productType === 'licensed') {
return LicensedProduct::class; 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; return $className;
} }
@@ -81,7 +107,7 @@ final class LicensedProductType
$tabs['licensed_product'] = [ $tabs['licensed_product'] = [
'label' => __('License Settings', 'wc-licensed-product'), 'label' => __('License Settings', 'wc-licensed-product'),
'target' => 'licensed_product_data', 'target' => 'licensed_product_data',
'class' => ['show_if_licensed'], 'class' => ['show_if_licensed', 'show_if_licensed-variable'],
'priority' => 21, 'priority' => 21,
]; ];
return $tabs; return $tabs;
@@ -236,9 +262,16 @@ final class LicensedProductType
*/ */
public function isVirtual(bool $isVirtual, \WC_Product $product): bool 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; 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; return $isVirtual;
} }
@@ -253,7 +286,7 @@ final class LicensedProductType
global $product; global $product;
if (!$product || !$product->is_type('licensed')) { if (!$product || (!$product->is_type('licensed') && !$product->is_type('licensed-variable'))) {
return; return;
} }
@@ -272,11 +305,11 @@ final class LicensedProductType
{ {
global $product; global $product;
if (!$product || !$product->is_type('licensed')) { if (!$product || (!$product->is_type('licensed') && !$product->is_type('licensed-variable'))) {
return; return;
} }
/** @var LicensedProduct $product */ /** @var LicensedProduct|LicensedVariableProduct $product */
$version = $product->get_current_version(); $version = $product->get_current_version();
if (empty($version)) { if (empty($version)) {
@@ -289,4 +322,200 @@ final class LicensedProductType
esc_html($version) 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,62 +1,129 @@
{% if not has_licenses %} {% if not has_packages %}
<p>{{ __('You have no licenses yet.') }}</p> <p>{{ __('You have no licenses yet.') }}</p>
{% else %} {% else %}
<div class="woocommerce-licenses"> <div class="woocommerce-licenses">
{% for item in licenses %} {% for package in packages %}
<div class="license-card"> <div class="license-package">
<div class="license-header"> <div class="package-header">
<div class="package-title">
<h3> <h3>
{% if item.product_url %} {% if package.product_url %}
<a href="{{ esc_url(item.product_url) }}">{{ esc_html(item.product_name) }}</a> <a href="{{ esc_url(package.product_url) }}">{{ esc_html(package.product_name) }}</a>
{% else %} {% else %}
{{ esc_html(item.product_name) }} {{ esc_html(package.product_name) }}
{% endif %} {% endif %}
</h3> </h3>
<span class="license-status license-status-{{ esc_attr(item.license.status) }}"> <span class="package-order">
{{ esc_html(item.license.status)|capitalize }} {{ __('Order') }}
{% if package.order_url %}
<a href="{{ esc_url(package.order_url) }}">#{{ esc_html(package.order_number) }}</a>
{% else %}
#{{ esc_html(package.order_number) }}
{% endif %}
</span>
</div>
<span class="package-license-count">
{{ package.licenses|length }} {{ package.licenses|length == 1 ? __('License') : __('Licenses') }}
</span> </span>
</div> </div>
<div class="license-details"> <div class="package-licenses">
<div class="license-key-row"> {% for license in package.licenses %}
<label>{{ __('License Key:') }}</label> <div class="license-entry license-entry-{{ esc_attr(license.status) }}">
<code class="license-key" data-license-key="{{ esc_attr(item.license.licenseKey) }}"> <div class="license-row-primary">
{{ esc_html(item.license.licenseKey) }} <div class="license-key-group">
</code> <code class="license-key">{{ esc_html(license.license_key) }}</code>
<button type="button" class="copy-license-btn" data-license-key="{{ esc_attr(item.license.licenseKey) }}" title="{{ __('Copy to clipboard') }}"> <span class="license-status license-status-{{ esc_attr(license.status) }}">
{{ 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> <span class="dashicons dashicons-clipboard"></span>
</button> </button>
</div> </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>
{% endif %}
</span>
<span><strong>{{ __('Expires:') }}</strong>
{% if item.license.expiresAt %}
{{ item.license.expiresAt|date('Y-m-d') }}
{% else %}
{{ __('Never') }}
{% endif %}
</span>
</div> </div>
</div> </div>
{% endif %}
</div>
{% endfor %}
</div>
{% if item.downloads is defined and item.downloads is not empty %} {% if package.downloads is defined and package.downloads is not empty %}
<div class="license-downloads"> <div class="package-downloads">
<h4>{{ __('Available Downloads') }}</h4> <h4>{{ __('Available Downloads') }}</h4>
<ul class="download-list"> <ul class="download-list">
{% for download in item.downloads %} {# Show only the latest version (first item) #}
{% 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"> <li class="download-item">
<div class="download-row-file"> <div class="download-row-file">
<a href="{{ esc_url(download.download_url) }}" class="download-link"> <a href="{{ esc_url(download.download_url) }}" class="download-link">
@@ -79,6 +146,8 @@
</div> </div>
{% endif %} {% endif %}
</div> </div>
{% endif %}
</div>
{% endfor %} {% endfor %}
</div> </div>

View File

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