38 Commits

Author SHA1 Message Date
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
a93381dce6 Bump version to 0.3.8
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-24 15:34:38 +01:00
a522455a0a Fix duplicate translation string causing sprintf error
Removed duplicated German translation text that had two %s placeholders
causing ArgumentCountError in settings page. Updated composer.lock with
latest client library (64d215c).

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-24 15:34:06 +01:00
2de6abe133 Update CLAUDE.md with v0.3.7 session history
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-24 10:21:49 +01:00
8d60758f23 Add release package v0.3.7
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-24 10:19:53 +01:00
82bec621c6 Bump version to 0.3.7
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-24 10:18:33 +01:00
034593f896 Dashboard widget improvements and download counter feature (v0.3.7)
- Fixed: Dashboard widget "View All Licenses" link used wrong page slug
- Fixed: Download links in customer account resulted in 404 errors
- Removed: Redundant "Status Breakdown" section from dashboard widget
- Changed: License Types section now uses card style layout
- Added: Download counter for licensed product versions
- Added: Download Statistics admin dashboard widget
- Updated translations (356 strings)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-24 10:17:46 +01:00
202f8a6dc0 Update composer.lock with latest client library
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 21:23:27 +01:00
36b51c9fc8 Update CLAUDE.md with v0.3.6 session history
- Document security hardening changes (CSRF, IP spoofing, XSS)
- Add recursive key sorting fix for response signing
- Document trusted proxy configuration
- Add release information (SHA256, package size)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 21:22:26 +01:00
d0aaf3180f Merge branch 'main' into dev 2026-01-23 21:21:25 +01:00
35d802c2b8 Security improvements and API compatibility fixes (v0.3.6)
- Add recursive key sorting for response signing compatibility
- Fix IP header spoofing in rate limiting with trusted proxy support
- Add CSRF protection to CSV export with nonce verification
- Explicit Twig autoescape for XSS prevention
- Escape status values in CSS classes
- Update README with security documentation and trusted proxy config
- Update translations for v0.3.6

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 21:18:32 +01:00
4e683e2ff4 Update CLAUDE.md roadmap after v0.3.5 release
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 16:24:37 +01:00
c7967f71ab Update translations for v0.3.5
- Added translations for dashboard widget strings
- Added translations for license expired email strings
- Updated fuzzy translations with proper German text
- Compiled .mo file for production use

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 16:10:51 +01:00
1de8257527 Add dashboard widget and auto-expire license cron (v0.3.5)
- Add admin dashboard widget with license statistics
- Add daily wp-cron to auto-expire licenses past expiration date
- Add LicenseExpiredEmail notification for expired licenses
- Add getExpiredActiveLicenses() and autoExpireLicense() to LicenseManager

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 16:05:52 +01:00
26245c0c57 Remove redundant version badge from download list
Version is already shown in the download filename

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 12:10:50 +01:00
a6c6d247aa Improve download list layout in customer account (v0.3.5)
- Downloads now displayed in two-row format per entry
- First row: file download link
- Second row: metadata (version, date, checksum)
- Better visual separation and readability

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 12:07:49 +01:00
fba8bf2352 Add release package for v0.3.4
- wc-licensed-product-0.3.4.zip (784 KB)
- SHA256: 36a81c00eb03adf5dfa633891664d44b7e5225bf1ee594904f8acc9adec6bb47

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 12:02:28 +01:00
12a3a37658 Add product version display on single product page (v0.3.4)
- Display current version under product title for licensed products
- Add frontend CSS styling for version badge
- Update translations for new "Version:" string
- Bump version to 0.3.4

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 12:01:11 +01:00
b1fe34adfd Add v0.3.3 release package
Package: wc-licensed-product-0.3.3.zip (795 KB)
SHA256: a06d29eabc2da08613ae13874ed152b8ea9363b8284a2e9bdda414e32777558c

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 11:46:33 +01:00
51 changed files with 5729 additions and 1758 deletions

View File

@@ -7,6 +7,194 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased] ## [Unreleased]
## [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
### Fixed
- Fixed duplicate German translation string causing `ArgumentCountError` in settings page
- The notification settings description had duplicated text with two `%s` placeholders
### Changed
- Updated `magdev/wc-licensed-product-client` to latest version (64d215c)
## [0.3.7] - 2026-01-24
### Added
- Download counter for licensed product versions (tracked per version)
- Download Statistics admin dashboard widget showing total downloads, top products, and top versions
- New `DownloadWidgetController` class for download statistics widget
- New `incrementDownloadCount()`, `getTotalDownloadCount()`, and `getDownloadStatistics()` methods in `VersionManager`
- New `download_count` column in product versions database table
### Fixed
- Dashboard widget "View All Licenses" link now uses correct page slug (`wc-licenses`)
- Download links in customer account page no longer result in 404 errors (added query var registration)
- Added `license-download` endpoint registration during plugin activation
### Changed
- Removed redundant "Status Breakdown" section from dashboard widget (info already shown in stat cards)
- License Types section in dashboard widget now uses card style matching the stats row above
- Improved dashboard widget visual consistency
### Technical Details
- Added `addDownloadQueryVar()` method to `DownloadController` for proper endpoint registration
- Updated `Installer::activate()` to register `license-download` endpoint before flushing rewrite rules
- Updated translations (356 strings)
## [0.3.6] - 2026-01-23
### Security
- Added CSRF protection (nonce verification) to CSV export functionality
- Fixed IP header spoofing vulnerability in rate limiting - now requires explicit trusted proxy configuration
- Enabled explicit Twig autoescape for XSS protection
- Fixed unescaped status values in CSS classes in Twig templates
### Fixed
- Fixed response signing to use recursive key sorting for client compatibility
- ResponseSigner now recursively sorts nested array keys alphabetically as required by client implementation
### Changed
- Rate limiting now only trusts proxy headers when `WC_LICENSE_TRUSTED_PROXIES` constant is defined
- Added Cloudflare IP range support via `WC_LICENSE_TRUSTED_PROXIES = 'CLOUDFLARE'` configuration
- Improved IP detection with CIDR notation support for trusted proxy ranges
### Technical Details
- Added `recursiveKeySort()` method to `ResponseSigner` for proper response signing
- Added `isTrustedProxy()`, `isCloudflareIp()`, and `ipMatchesCidr()` methods to `RestApiController`
- Twig environment now explicitly sets `autoescape => 'html'`
- Export CSV link now includes nonce via `wp_nonce_url()`
- Added `export_csv_url()` Twig function for generating export URL with nonce
## [0.3.5] - 2026-01-23
### Added
- Admin dashboard widget showing license statistics on WordPress dashboard
- Automatic license expiration via daily wp-cron job
- License expired email notification sent when license auto-expires
- New `LicenseExpiredEmail` WooCommerce email class (configurable via WooCommerce > Settings > Emails)
### Changed
- Improved download list layout in customer account licenses page
- Downloads now displayed in two-row format: file link on first row, metadata on second row
- Better visual separation between download link and version/date/checksum information
### Technical Details
- New `DashboardWidgetController` class in `src/Admin/` for WordPress dashboard widget
- Widget displays: total licenses, active, expiring soon, expired counts, status breakdown, license types
- New `LicenseExpiredEmail` class in `src/Email/` for expired license notifications
- Added `getExpiredActiveLicenses()` and `autoExpireLicense()` methods to `LicenseManager`
- Daily cron now auto-expires licenses with past expiration date and sends notification emails
- Updated `templates/frontend/licenses.html.twig` with new two-row structure
- Added `.download-item`, `.download-row-file`, `.download-row-meta` CSS classes
- Improved responsive behavior for download metadata
## [0.3.4] - 2026-01-23
### Added
- Current version display on single product pages for licensed products
- Version number shown directly under the product title
- Frontend CSS styling for version badge with monospace font
### Technical Details
- Added `displayCurrentVersion()` method to `LicensedProductType` class
- Hooked to `woocommerce_single_product_summary` at priority 6 (after title)
- Added `enqueueFrontendStyles()` to load CSS on product pages
- Uses `LicensedProduct::get_current_version()` to fetch latest version
## [0.3.3] - 2026-01-22 ## [0.3.3] - 2026-01-22
### Fixed ### Fixed

390
CLAUDE.md
View File

@@ -36,6 +36,14 @@ This project is proudly **"vibe-coded"** using Claude.AI - the entire codebase w
No known bugs at the moment. No known bugs at the moment.
### Version 0.6.0
*No planned features yet.*
### Version 0.5.1
*No planned bugfixes yet.*
## Technical Stack ## Technical Stack
- **Language:** PHP 8.3.x - **Language:** PHP 8.3.x
@@ -878,3 +886,385 @@ Updated OpenAPI specification to document response signing feature added in v0.2
- Created release package: `releases/wc-licensed-product-0.3.2.zip` (810 KB) - Created release package: `releases/wc-licensed-product-0.3.2.zip` (810 KB)
- SHA256: `ca33c81516b5dcf4a80b3192d8ae4ad39a7bf67196a1f729b563c5ae01b1d39c` - SHA256: `ca33c81516b5dcf4a80b3192d8ae4ad39a7bf67196a1f729b563c5ae01b1d39c`
- Tagged as `v0.3.2` and pushed to `main` branch - Tagged as `v0.3.2` and pushed to `main` branch
### 2026-01-22 - Version 0.3.3 - Bug Fix & License Testing
**Overview:**
Fixed version deactivation bug and added license testing functionality.
**Bug Fix:**
- Fixed version deactivation button not working in admin product versions table
- Root cause: Parameters in wrong order in `VersionAdminController::ajaxToggleVersion()`
- Changed from `updateVersion($versionId, null, null, !$currentlyActive)` to `updateVersion($versionId, null, !$currentlyActive, null)`
**Implemented:**
- Added "Test" action to license overview to validate licenses against `/validate` API endpoint
- Test License modal showing license key, domain, and validation results
- AJAX handler `handleAjaxTestLicense()` for license testing
**Modified files:**
- `src/Admin/VersionAdminController.php` - Fixed parameter order in toggle method
- `src/Admin/AdminController.php` - Added Test action to PHP fallback and AJAX handler
- `templates/admin/licenses.html.twig` - Added Test action and modal to Twig template
**Release v0.3.3:**
- Created release package: `releases/wc-licensed-product-0.3.3.zip` (795 KB)
- SHA256: `a06d29eabc2da08613ae13874ed152b8ea9363b8284a2e9bdda414e32777558c`
- Tagged as `v0.3.3` and pushed to `main` branch
### 2026-01-23 - Version 0.3.4 - Frontend Version Display
**Overview:**
Added current version display on single product pages for licensed products.
**Implemented:**
- Current version displayed directly under the product title
- Styled version badge with monospace font and subtle blue background
- Frontend CSS automatically loaded on licensed product pages
**Modified files:**
- `src/Product/LicensedProductType.php` - Added `displayCurrentVersion()` and `enqueueFrontendStyles()` methods
- `assets/css/frontend.css` - Added `.wclp-product-version` styles
**Technical notes:**
- Uses `woocommerce_single_product_summary` hook at priority 6 (after title at priority 5)
- Only displays for licensed product type
- Only displays if product has at least one version defined
- Uses `LicensedProduct::get_current_version()` which queries `VersionManager::getLatestVersion()`
### 2026-01-23 - Version 0.3.5 - Dashboard Widget & Auto-Expire
**Overview:**
Added admin dashboard widget for license statistics and automatic license expiration via daily cron job.
**Implemented:**
- Admin dashboard widget showing license statistics (total, active, expiring soon, expired)
- Status breakdown display with color-coded badges
- License type breakdown (time-limited vs lifetime)
- Daily wp-cron job to auto-expire licenses past their expiration date
- License expired email notification sent when license auto-expires
- Downloads in customer account now displayed in two-row format
**New files:**
- `src/Admin/DashboardWidgetController.php` - WordPress dashboard widget controller
- `src/Email/LicenseExpiredEmail.php` - WooCommerce email for expired license notifications
**Modified files:**
- `src/Plugin.php` - Added DashboardWidgetController instantiation
- `src/License/LicenseManager.php` - Added `getExpiredActiveLicenses()` and `autoExpireLicense()` methods
- `src/Email/LicenseEmailController.php` - Added auto-expire logic and LicenseExpiredEmail registration
- `templates/frontend/licenses.html.twig` - Restructured download list with two-row layout
- `assets/css/frontend.css` - Added dashboard widget and download list styles
**Technical notes:**
- Dashboard widget uses `wp_add_dashboard_widget()` hook, requires `manage_woocommerce` capability
- Widget displays statistics from existing `LicenseManager::getStatistics()` method
- Auto-expire runs during daily `wclp_check_expiring_licenses` cron event
- `getExpiredActiveLicenses()` finds licenses with past expiration date but still active status
- `autoExpireLicense()` updates status to expired and returns true if changed
- LicenseExpiredEmail follows same pattern as LicenseExpirationEmail (warning vs expired)
- Expired notification tracked via user meta to prevent duplicate emails
### 2026-01-23 - Version 0.3.6 - Security Hardening
**Overview:**
Security audit and implementation alignment with client/server documentation. Fixed response signing compatibility, rate limiting security, and XSS prevention.
**Security Fixes:**
- Added CSRF protection (nonce verification) to CSV export functionality
- Fixed IP header spoofing vulnerability in rate limiting - now requires explicit trusted proxy configuration
- Enabled explicit Twig autoescape (`'html'`) for XSS protection
- Fixed unescaped status values in CSS class names in Twig templates
**Implementation Fixes:**
- Fixed response signing to use recursive key sorting for client library compatibility
- ResponseSigner now recursively sorts nested array keys alphabetically as required by `magdev/wc-licensed-product-client`
**Modified files:**
- `src/Api/ResponseSigner.php` - Added `recursiveKeySort()` method for proper signature generation
- `src/Api/RestApiController.php` - Added trusted proxy support with `isTrustedProxy()`, `isCloudflareIp()`, `ipMatchesCidr()` methods
- `src/Plugin.php` - Added explicit `autoescape => 'html'` to Twig environment
- `src/Admin/AdminController.php` - Added nonce verification to `handleCsvExport()`, added `export_csv_url()` Twig function
- `templates/frontend/licenses.html.twig` - Added `esc_attr()` for CSS class status
- `templates/admin/licenses.html.twig` - Added `esc_attr()` for CSS class status, updated export link to use `export_csv_url()`
**Configuration:**
To enable trusted proxy support for rate limiting, add to `wp-config.php`:
```php
// For Cloudflare
define('WC_LICENSE_TRUSTED_PROXIES', 'CLOUDFLARE');
// Or for specific IPs/CIDR ranges
define('WC_LICENSE_TRUSTED_PROXIES', '10.0.0.1,192.168.1.0/24');
```
**Technical notes:**
- Rate limiting now only trusts proxy headers (`HTTP_CF_CONNECTING_IP`, `HTTP_X_FORWARDED_FOR`, `HTTP_X_REAL_IP`) when `WC_LICENSE_TRUSTED_PROXIES` constant is defined
- Without trusted proxy configuration, rate limiting uses `REMOTE_ADDR` only (prevents IP spoofing)
- Cloudflare IP ranges are hardcoded for convenience (as of 2024)
- CIDR notation supported for custom proxy ranges
- Recursive key sorting ensures signature compatibility with SecureLicenseClient
- References: <https://src.bundespruefstelle.ch/magdev/wc-licensed-product-client/raw/branch/main/docs/server-implementation.md>
**Release v0.3.6:**
- Created release package: `releases/wc-licensed-product-0.3.6.zip` (818 KB)
- SHA256: `b0063f0312759f090e12faba83de730baf4114139d763e46fad2b781d4b38270`
- Tagged as `v0.3.6` and pushed to `main` branch
### 2026-01-24 - Version 0.3.7 - Dashboard Improvements & Download Counter
**Overview:**
Fixed dashboard widget bugs, improved UI consistency, and added download tracking functionality with a new statistics widget.
**Bug Fixes:**
- Fixed: Dashboard widget "View All Licenses" link used wrong page slug (`wc-licensed-product-licenses` instead of `wc-licenses`)
- Fixed: Download links in customer account resulted in 404 errors due to missing query var registration
- Added `license-download` endpoint registration during plugin activation in `Installer::activate()`
- Added `addDownloadQueryVar()` method to `DownloadController` for proper WordPress endpoint recognition
**UI Improvements:**
- Removed redundant "Status Breakdown" section from license statistics widget (info already shown in stat cards above)
- Changed License Types section to use card-style layout matching the stats row above
- Cleaned up unused CSS for status badges
**New Features:**
- Download counter for licensed product versions (tracked per version in database)
- New Download Statistics admin dashboard widget showing:
- Total downloads count
- Top 5 products by downloads
- Top 5 versions by downloads
**New files:**
- `src/Admin/DownloadWidgetController.php` - Dashboard widget for download statistics
**New methods in VersionManager:**
- `incrementDownloadCount()` - Atomically increment download count for a version
- `getTotalDownloadCount()` - Get total downloads across all versions
- `getDownloadStatistics()` - Get download stats grouped by product and version
**Modified files:**
- `src/Installer.php` - Added `download_count` column to versions table, added `license-download` endpoint registration
- `src/Product/ProductVersion.php` - Added `downloadCount` property and `getDownloadCount()` method
- `src/Product/VersionManager.php` - Added download counting methods
- `src/Frontend/DownloadController.php` - Added query var registration, increment download count on file serve
- `src/Admin/DashboardWidgetController.php` - Fixed URL, removed Status Breakdown, changed License Types to cards
- `src/Plugin.php` - Added DownloadWidgetController instantiation
**Technical notes:**
- Download count is incremented atomically using SQL `download_count = download_count + 1`
- Statistics queries use SQL aggregation with product name enrichment via `wc_get_product()`
- WordPress endpoints require both `add_rewrite_endpoint()` AND `query_vars` filter registration
- Existing installations need to flush rewrite rules (Settings > Permalinks > Save) or reactivate plugin
**Release v0.3.7:**
- Created release package: `releases/wc-licensed-product-0.3.7.zip` (827 KB)
- SHA256: `e93b2ab06f6d43c2179167090e07eda5db6809df6e391baece4ceba321cf33f6`
- 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

View File

@@ -14,10 +14,13 @@ WC Licensed Product adds a new product type "Licensed Product" to WooCommerce, e
- **Automatic License Generation**: License keys generated on order completion (format: XXXX-XXXX-XXXX-XXXX) - **Automatic License Generation**: License keys generated on order completion (format: XXXX-XXXX-XXXX-XXXX)
- **Domain Binding**: Licenses are bound to customer-specified domains - **Domain Binding**: Licenses are bound to customer-specified domains
- **REST API**: Public endpoints for license validation and management - **REST API**: Public endpoints for license validation and management
- **Response Signing**: Optional HMAC-SHA256 cryptographic signatures for API responses
- **Version Binding**: Optional binding to major software versions - **Version Binding**: Optional binding to major software versions
- **Expiration Support**: Set license validity periods or lifetime licenses - **Expiration Support**: Set license validity periods or lifetime licenses
- **Rate Limiting**: API endpoints protected with rate limiting (30 requests/minute) - **Rate Limiting**: API endpoints protected with rate limiting (30 requests/minute)
- **Trusted Proxy Support**: Configurable trusted proxies for accurate rate limiting behind CDNs
- **Checkout Blocks**: Full support for WooCommerce Checkout Blocks (default since WC 8.3+) - **Checkout Blocks**: Full support for WooCommerce Checkout Blocks (default since WC 8.3+)
- **Self-Licensing**: The plugin can validate its own license (for commercial distribution)
### Customer Features ### Customer Features
@@ -30,6 +33,7 @@ WC Licensed Product adds a new product type "Licensed Product" to WooCommerce, e
- **License Management**: Full CRUD interface for license management - **License Management**: Full CRUD interface for license management
- **License Dashboard**: Statistics and analytics (WooCommerce > Reports > Licenses) - **License Dashboard**: Statistics and analytics (WooCommerce > Reports > Licenses)
- **Dashboard Widget**: License statistics on WordPress admin dashboard
- **Search & Filtering**: Search by license key, domain, status, or product - **Search & Filtering**: Search by license key, domain, status, or product
- **Live Search**: AJAX-powered instant search results - **Live Search**: AJAX-powered instant search results
- **Inline Editing**: Edit license status, expiry, and domain directly in the list - **Inline Editing**: Edit license status, expiry, and domain directly in the list
@@ -38,7 +42,10 @@ WC Licensed Product adds a new product type "Licensed Product" to WooCommerce, e
- **CSV Export/Import**: Export and import licenses via CSV - **CSV Export/Import**: Export and import licenses via CSV
- **Order Integration**: View and manage licenses directly from order pages - **Order Integration**: View and manage licenses directly from order pages
- **Expiration Warnings**: Automatic email notifications before license expiration - **Expiration Warnings**: Automatic email notifications before license expiration
- **Auto-Expire**: Daily cron job automatically expires licenses past their expiration date
- **License Testing**: Test licenses against the API directly from admin interface
- **Version Management**: Manage multiple versions per product with file attachments - **Version Management**: Manage multiple versions per product with file attachments
- **SHA256 Checksums**: File integrity verification with SHA256 hash display
- **Global Settings**: Default license settings via WooCommerce settings tab - **Global Settings**: Default license settings via WooCommerce settings tab
- **WooCommerce HPOS**: Compatible with High-Performance Order Storage - **WooCommerce HPOS**: Compatible with High-Performance Order Storage
@@ -103,6 +110,40 @@ When a customer purchases a licensed product, they must enter the domain where t
3. Upload a CSV file (supports exported format or simplified format) 3. Upload a CSV file (supports exported format or simplified format)
4. Choose options: skip header row, update existing licenses 4. Choose options: skip header row, update existing licenses
## Security
The plugin implements several security best practices:
- **Input Sanitization**: All user inputs are sanitized using WordPress functions
- **Output Escaping**: All output is escaped to prevent XSS attacks
- **CSRF Protection**: Nonce verification on all forms and AJAX requests
- **SQL Injection Prevention**: All database queries use prepared statements
- **Capability Checks**: Admin functions require `manage_woocommerce` capability
- **Secure Downloads**: File downloads use hash-verified URLs with user authentication
- **Response Signing**: Optional HMAC-SHA256 signatures for API tamper protection
### Trusted Proxy Configuration
If your server is behind a load balancer, reverse proxy, or CDN (like Cloudflare), you need to configure trusted proxies for accurate rate limiting. Without this, the rate limiter uses the direct connection IP which may be your proxy's IP.
**Configuration (wp-config.php):**
```php
// For Cloudflare (includes all Cloudflare IP ranges)
define('WC_LICENSE_TRUSTED_PROXIES', 'CLOUDFLARE');
// For specific proxy IPs
define('WC_LICENSE_TRUSTED_PROXIES', '10.0.0.1,10.0.0.2');
// For CIDR ranges
define('WC_LICENSE_TRUSTED_PROXIES', '10.0.0.0/8,192.168.1.0/24');
// Combine multiple methods
define('WC_LICENSE_TRUSTED_PROXIES', 'CLOUDFLARE,10.0.0.1');
```
**Note**: Only configure trusted proxies if you actually use them. Without this configuration, rate limiting is more secure against IP spoofing attacks.
## REST API ## REST API
Full API documentation available in `openapi.json` (OpenAPI 3.1 specification). Full API documentation available in `openapi.json` (OpenAPI 3.1 specification).
@@ -117,6 +158,12 @@ When the server is configured with a shared secret, all API responses include cr
define('WC_LICENSE_SERVER_SECRET', 'your-secure-random-string-min-32-chars'); define('WC_LICENSE_SERVER_SECRET', 'your-secure-random-string-min-32-chars');
``` ```
Generate a secure secret using:
```bash
openssl rand -hex 32
```
**Response Headers:** **Response Headers:**
| Header | Description | | Header | Description |
@@ -256,11 +303,12 @@ Content-Type: application/json
## Email Notifications ## Email Notifications
The plugin sends automatic email notifications: The plugin sends automatic email notifications (configurable via WooCommerce > Settings > Emails):
- **Order Completion**: License keys included in order confirmation emails - **Order Completion**: License keys included in order confirmation emails
- **Expiration Warning (7 days)**: Reminder sent 7 days before expiration - **Expiration Warning (7 days)**: Reminder sent 7 days before expiration
- **Expiration Warning (1 day)**: Urgent reminder sent 1 day before expiration - **Expiration Warning (1 day)**: Urgent reminder sent 1 day before expiration
- **License Expired**: Notification when a license auto-expires
## Changelog ## Changelog

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;
@@ -202,18 +387,30 @@
padding: 0; padding: 0;
} }
.download-list li { .download-list li.download-item {
display: flex; display: flex;
align-items: center; flex-direction: column;
gap: 1em; gap: 0.35em;
padding: 0.5em 0; padding: 0.75em 0;
border-bottom: 1px solid #eee; border-bottom: 1px solid #eee;
} }
.download-list li:last-child { .download-list li.download-item:last-child {
border-bottom: none; border-bottom: none;
} }
.download-row-file {
display: flex;
align-items: center;
}
.download-row-meta {
display: flex;
align-items: center;
gap: 1em;
padding-left: 1.5em;
}
.download-link { .download-link {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
@@ -244,7 +441,6 @@
.download-date { .download-date {
color: #999; color: #999;
font-size: 0.85em; font-size: 0.85em;
margin-left: auto;
} }
.download-hash { .download-hash {
@@ -271,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;
@@ -322,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;
@@ -338,42 +645,49 @@
gap: 0.5em; gap: 0.5em;
} }
.download-list li { .download-row-meta {
padding-left: 0;
flex-wrap: wrap; flex-wrap: wrap;
} }
.download-date { /* Legacy table responsive */
margin-left: 0;
width: 100%;
}
.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;
@@ -528,3 +842,24 @@
color: #721c24; color: #721c24;
border: 1px solid #f5c6cb; border: 1px solid #f5c6cb;
} }
/* Product Version Display (Single Product Page) */
.wclp-product-version {
margin: 0.5em 0 1em 0;
font-size: 0.95em;
color: #666;
}
.wclp-product-version .version-label {
font-weight: 500;
color: #555;
}
.wclp-product-version .version-number {
font-family: 'SF Mono', Monaco, Consolas, monospace;
background: #e7f3ff;
padding: 0.15em 0.5em;
border-radius: 3px;
color: #2271b1;
font-weight: 500;
}

View File

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

View File

@@ -25,6 +25,9 @@
$(document).on('click', '.wclp-modal-close, .wclp-modal-cancel, .wclp-modal-overlay', this.closeTransferModal.bind(this)); $(document).on('click', '.wclp-modal-close, .wclp-modal-cancel, .wclp-modal-overlay', this.closeTransferModal.bind(this));
$(document).on('submit', '#wclp-transfer-form', this.submitTransfer.bind(this)); $(document).on('submit', '#wclp-transfer-form', this.submitTransfer.bind(this));
// Older versions toggle
$(document).on('click', '.older-versions-toggle', this.toggleOlderVersions);
// Close modal on escape key // Close modal on escape key
$(document).on('keyup', function(e) { $(document).on('keyup', function(e) {
if (e.key === 'Escape') { if (e.key === 'Escape') {
@@ -33,6 +36,20 @@
}); });
}, },
/**
* Toggle older versions visibility
*/
toggleOlderVersions: function(e) {
e.preventDefault();
var $btn = $(this);
var $list = $btn.siblings('.older-versions-list');
var isExpanded = $btn.attr('aria-expanded') === 'true';
$btn.attr('aria-expanded', !isExpanded);
$list.slideToggle(200);
},
/** /**
* Copy license key to clipboard * Copy license key to clipboard
*/ */

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('');

16
composer.lock generated
View File

@@ -12,7 +12,7 @@
"source": { "source": {
"type": "git", "type": "git",
"url": "https://src.bundespruefstelle.ch/magdev/wc-licensed-product-client.git", "url": "https://src.bundespruefstelle.ch/magdev/wc-licensed-product-client.git",
"reference": "83037ea0c2d9e365cf9ec0ad50251d3ebc7e4782" "reference": "64d215cb265a64ff318cfbb954dd128b0076dc1d"
}, },
"require": { "require": {
"php": "^8.3", "php": "^8.3",
@@ -52,7 +52,7 @@
"issues": "https://src.bundespruefstelle.ch/magdev/wc-licensed-product-client/issues", "issues": "https://src.bundespruefstelle.ch/magdev/wc-licensed-product-client/issues",
"source": "https://src.bundespruefstelle.ch/magdev/wc-licensed-product-client" "source": "https://src.bundespruefstelle.ch/magdev/wc-licensed-product-client"
}, },
"time": "2026-01-22T15:24:57+00:00" "time": "2026-01-24T13:32:11+00:00"
}, },
{ {
"name": "psr/cache", "name": "psr/cache",
@@ -894,16 +894,16 @@
}, },
{ {
"name": "twig/twig", "name": "twig/twig",
"version": "v3.22.2", "version": "v3.23.0",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/twigphp/Twig.git", "url": "https://github.com/twigphp/Twig.git",
"reference": "946ddeafa3c9f4ce279d1f34051af041db0e16f2" "reference": "a64dc5d2cc7d6cafb9347f6cd802d0d06d0351c9"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/twigphp/Twig/zipball/946ddeafa3c9f4ce279d1f34051af041db0e16f2", "url": "https://api.github.com/repos/twigphp/Twig/zipball/a64dc5d2cc7d6cafb9347f6cd802d0d06d0351c9",
"reference": "946ddeafa3c9f4ce279d1f34051af041db0e16f2", "reference": "a64dc5d2cc7d6cafb9347f6cd802d0d06d0351c9",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@@ -957,7 +957,7 @@
], ],
"support": { "support": {
"issues": "https://github.com/twigphp/Twig/issues", "issues": "https://github.com/twigphp/Twig/issues",
"source": "https://github.com/twigphp/Twig/tree/v3.22.2" "source": "https://github.com/twigphp/Twig/tree/v3.23.0"
}, },
"funding": [ "funding": [
{ {
@@ -969,7 +969,7 @@
"type": "tidelift" "type": "tidelift"
} }
], ],
"time": "2025-12-14T11:28:47+00:00" "time": "2026-01-23T21:00:41+00:00"
} }
], ],
"packages-dev": [], "packages-dev": [],

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1 @@
a06d29eabc2da08613ae13874ed152b8ea9363b8284a2e9bdda414e32777558c wc-licensed-product-0.3.3.zip

Binary file not shown.

View File

@@ -0,0 +1 @@
36a81c00eb03adf5dfa633891664d44b7e5225bf1ee594904f8acc9adec6bb47 releases/wc-licensed-product-0.3.4.zip

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1 @@
e93b2ab06f6d43c2179167090e07eda5db6809df6e391baece4ceba321cf33f6 wc-licensed-product-0.3.7.zip

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

@@ -572,6 +572,11 @@ final class AdminController
*/ */
private function handleCsvExport(): void private function handleCsvExport(): void
{ {
// Verify nonce for CSRF protection
if (!wp_verify_nonce($_GET['_wpnonce'] ?? '', 'export_licenses_csv')) {
wp_die(__('Security check failed.', 'wc-licensed-product'));
}
if (!current_user_can('manage_woocommerce')) { if (!current_user_can('manage_woocommerce')) {
wp_die(__('You do not have permission to export licenses.', 'wc-licensed-product')); wp_die(__('You do not have permission to export licenses.', 'wc-licensed-product'));
} }
@@ -954,7 +959,7 @@ final class AdminController
<span class="dashicons dashicons-admin-network"></span> <span class="dashicons dashicons-admin-network"></span>
<?php esc_html_e('Manage Licenses', 'wc-licensed-product'); ?> <?php esc_html_e('Manage Licenses', 'wc-licensed-product'); ?>
</a> </a>
<a href="<?php echo esc_url(admin_url('admin.php?page=wc-licenses&action=export_csv')); ?>" class="button"> <a href="<?php echo esc_url(wp_nonce_url(admin_url('admin.php?page=wc-licenses&action=export_csv'), 'export_licenses_csv')); ?>" class="button">
<span class="dashicons dashicons-download"></span> <span class="dashicons dashicons-download"></span>
<?php esc_html_e('Export to CSV', 'wc-licensed-product'); ?> <?php esc_html_e('Export to CSV', 'wc-licensed-product'); ?>
</a> </a>
@@ -1048,6 +1053,12 @@ final class AdminController
$this->twig->addFunction(new \Twig\TwigFunction('transfer_nonce', function (): string { $this->twig->addFunction(new \Twig\TwigFunction('transfer_nonce', function (): string {
return wp_create_nonce('transfer_license'); return wp_create_nonce('transfer_license');
})); }));
$this->twig->addFunction(new \Twig\TwigFunction('export_csv_url', function (): string {
return wp_nonce_url(
admin_url('admin.php?page=wc-licenses&action=export_csv'),
'export_licenses_csv'
);
}));
try { try {
echo $this->twig->render('admin/licenses.html.twig', [ echo $this->twig->render('admin/licenses.html.twig', [
@@ -1187,7 +1198,7 @@ final class AdminController
?> ?>
<div class="wrap"> <div class="wrap">
<h1 class="wp-heading-inline"><?php esc_html_e('Licenses', 'wc-licensed-product'); ?></h1> <h1 class="wp-heading-inline"><?php esc_html_e('Licenses', 'wc-licensed-product'); ?></h1>
<a href="<?php echo esc_url(admin_url('admin.php?page=wc-licenses&action=export_csv')); ?>" class="page-title-action"> <a href="<?php echo esc_url(wp_nonce_url(admin_url('admin.php?page=wc-licenses&action=export_csv'), 'export_licenses_csv')); ?>" class="page-title-action">
<span class="dashicons dashicons-download" style="vertical-align: middle;"></span> <span class="dashicons dashicons-download" style="vertical-align: middle;"></span>
<?php esc_html_e('Export CSV', 'wc-licensed-product'); ?> <?php esc_html_e('Export CSV', 'wc-licensed-product'); ?>
</a> </a>

View File

@@ -0,0 +1,147 @@
<?php
/**
* Dashboard Widget Controller
*
* @package Jeremias\WcLicensedProduct\Admin
*/
declare(strict_types=1);
namespace Jeremias\WcLicensedProduct\Admin;
use Jeremias\WcLicensedProduct\License\License;
use Jeremias\WcLicensedProduct\License\LicenseManager;
/**
* Handles the WordPress admin dashboard widget for license statistics
*/
final class DashboardWidgetController
{
private LicenseManager $licenseManager;
public function __construct(LicenseManager $licenseManager)
{
$this->licenseManager = $licenseManager;
$this->registerHooks();
}
/**
* Register WordPress hooks
*/
private function registerHooks(): void
{
add_action('wp_dashboard_setup', [$this, 'registerDashboardWidget']);
}
/**
* Register the dashboard widget
*/
public function registerDashboardWidget(): void
{
if (!current_user_can('manage_woocommerce')) {
return;
}
wp_add_dashboard_widget(
'wclp_license_statistics',
__('License Statistics', 'wc-licensed-product'),
[$this, 'renderWidget']
);
}
/**
* Render the dashboard widget content
*/
public function renderWidget(): void
{
$stats = $this->licenseManager->getStatistics();
$licensesUrl = admin_url('admin.php?page=wc-licenses');
?>
<style>
.wclp-widget-stats {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 12px;
margin-bottom: 16px;
}
.wclp-stat-card {
background: #f8f9fa;
border: 1px solid #e2e4e7;
border-radius: 4px;
padding: 12px;
text-align: center;
}
.wclp-stat-card.highlight {
border-left: 3px solid #7f54b3;
}
.wclp-stat-card.warning {
border-left: 3px solid #f0b849;
}
.wclp-stat-card.danger {
border-left: 3px solid #dc3232;
}
.wclp-stat-card.success {
border-left: 3px solid #46b450;
}
.wclp-stat-number {
font-size: 28px;
font-weight: 600;
color: #1d2327;
line-height: 1.2;
}
.wclp-stat-label {
font-size: 12px;
color: #646970;
text-transform: uppercase;
letter-spacing: 0.5px;
margin-top: 4px;
}
.wclp-widget-footer {
margin-top: 16px;
padding-top: 12px;
border-top: 1px solid #e2e4e7;
text-align: center;
}
.wclp-widget-footer a {
text-decoration: none;
}
</style>
<div class="wclp-widget-stats">
<div class="wclp-stat-card highlight">
<div class="wclp-stat-number"><?php echo esc_html(number_format_i18n($stats['total'])); ?></div>
<div class="wclp-stat-label"><?php esc_html_e('Total Licenses', 'wc-licensed-product'); ?></div>
</div>
<div class="wclp-stat-card success">
<div class="wclp-stat-number"><?php echo esc_html(number_format_i18n($stats['by_status'][License::STATUS_ACTIVE])); ?></div>
<div class="wclp-stat-label"><?php esc_html_e('Active', 'wc-licensed-product'); ?></div>
</div>
<div class="wclp-stat-card warning">
<div class="wclp-stat-number"><?php echo esc_html(number_format_i18n($stats['expiring_soon'])); ?></div>
<div class="wclp-stat-label"><?php esc_html_e('Expiring Soon', 'wc-licensed-product'); ?></div>
</div>
<div class="wclp-stat-card danger">
<div class="wclp-stat-number"><?php echo esc_html(number_format_i18n($stats['by_status'][License::STATUS_EXPIRED])); ?></div>
<div class="wclp-stat-label"><?php esc_html_e('Expired', 'wc-licensed-product'); ?></div>
</div>
</div>
<div class="wclp-widget-stats">
<div class="wclp-stat-card">
<div class="wclp-stat-number"><?php echo esc_html(number_format_i18n($stats['expiring'])); ?></div>
<div class="wclp-stat-label"><?php esc_html_e('Time-limited', 'wc-licensed-product'); ?></div>
</div>
<div class="wclp-stat-card">
<div class="wclp-stat-number"><?php echo esc_html(number_format_i18n($stats['lifetime'])); ?></div>
<div class="wclp-stat-label"><?php esc_html_e('Lifetime', 'wc-licensed-product'); ?></div>
</div>
</div>
<div class="wclp-widget-footer">
<a href="<?php echo esc_url($licensesUrl); ?>" class="button button-secondary">
<?php esc_html_e('View All Licenses', 'wc-licensed-product'); ?>
</a>
</div>
<?php
}
}

View File

@@ -0,0 +1,184 @@
<?php
/**
* Download Statistics Widget Controller
*
* @package Jeremias\WcLicensedProduct\Admin
*/
declare(strict_types=1);
namespace Jeremias\WcLicensedProduct\Admin;
use Jeremias\WcLicensedProduct\Product\VersionManager;
/**
* Handles the WordPress admin dashboard widget for download statistics
*/
final class DownloadWidgetController
{
private VersionManager $versionManager;
public function __construct(VersionManager $versionManager)
{
$this->versionManager = $versionManager;
$this->registerHooks();
}
/**
* Register WordPress hooks
*/
private function registerHooks(): void
{
add_action('wp_dashboard_setup', [$this, 'registerDashboardWidget']);
}
/**
* Register the dashboard widget
*/
public function registerDashboardWidget(): void
{
if (!current_user_can('manage_woocommerce')) {
return;
}
wp_add_dashboard_widget(
'wclp_download_statistics',
__('Download Statistics', 'wc-licensed-product'),
[$this, 'renderWidget']
);
}
/**
* Render the dashboard widget content
*/
public function renderWidget(): void
{
$stats = $this->versionManager->getDownloadStatistics();
?>
<style>
.wclp-download-widget-stats {
display: grid;
grid-template-columns: 1fr;
gap: 12px;
margin-bottom: 16px;
}
.wclp-download-stat-card {
background: #f8f9fa;
border: 1px solid #e2e4e7;
border-radius: 4px;
padding: 12px;
text-align: center;
border-left: 3px solid #2271b1;
}
.wclp-download-stat-number {
font-size: 32px;
font-weight: 600;
color: #1d2327;
line-height: 1.2;
}
.wclp-download-stat-label {
font-size: 12px;
color: #646970;
text-transform: uppercase;
letter-spacing: 0.5px;
margin-top: 4px;
}
.wclp-download-list {
margin: 0;
padding: 0;
list-style: none;
}
.wclp-download-list li {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 0;
border-bottom: 1px solid #e2e4e7;
}
.wclp-download-list li:last-child {
border-bottom: none;
}
.wclp-download-list .product-name {
font-weight: 500;
color: #1d2327;
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
margin-right: 12px;
}
.wclp-download-list .version-info {
font-size: 12px;
color: #646970;
}
.wclp-download-list .download-count {
background: #e7f5ff;
color: #0a4b78;
padding: 2px 8px;
border-radius: 10px;
font-size: 12px;
font-weight: 600;
white-space: nowrap;
}
.wclp-download-section-title {
margin: 16px 0 8px 0;
font-size: 13px;
color: #1d2327;
font-weight: 600;
}
.wclp-no-downloads {
color: #646970;
font-style: italic;
text-align: center;
padding: 12px 0;
}
</style>
<div class="wclp-download-widget-stats">
<div class="wclp-download-stat-card">
<div class="wclp-download-stat-number"><?php echo esc_html(number_format_i18n($stats['total'])); ?></div>
<div class="wclp-download-stat-label"><?php esc_html_e('Total Downloads', 'wc-licensed-product'); ?></div>
</div>
</div>
<h4 class="wclp-download-section-title">
<?php esc_html_e('Top Products', 'wc-licensed-product'); ?>
</h4>
<?php if (!empty($stats['by_product'])): ?>
<ul class="wclp-download-list">
<?php foreach (array_slice($stats['by_product'], 0, 5) as $product): ?>
<li>
<span class="product-name"><?php echo esc_html($product['product_name']); ?></span>
<span class="download-count">
<?php echo esc_html(number_format_i18n($product['downloads'])); ?>
</span>
</li>
<?php endforeach; ?>
</ul>
<?php else: ?>
<p class="wclp-no-downloads"><?php esc_html_e('No downloads yet', 'wc-licensed-product'); ?></p>
<?php endif; ?>
<h4 class="wclp-download-section-title">
<?php esc_html_e('Top Versions', 'wc-licensed-product'); ?>
</h4>
<?php if (!empty($stats['by_version'])): ?>
<ul class="wclp-download-list">
<?php foreach (array_slice($stats['by_version'], 0, 5) as $version): ?>
<li>
<span class="product-name">
<?php echo esc_html($version['product_name']); ?>
<span class="version-info">v<?php echo esc_html($version['version']); ?></span>
</span>
<span class="download-count">
<?php echo esc_html(number_format_i18n($version['downloads'])); ?>
</span>
</li>
<?php endforeach; ?>
</ul>
<?php else: ?>
<p class="wclp-no-downloads"><?php esc_html_e('No downloads yet', 'wc-licensed-product'); ?></p>
<?php endif; ?>
<?php
}
}

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

@@ -94,8 +94,8 @@ final class ResponseSigner
$timestamp = time(); $timestamp = time();
$signingKey = $this->deriveKey($licenseKey); $signingKey = $this->deriveKey($licenseKey);
// Sort keys for consistent ordering // Recursively sort keys for consistent ordering (required by client implementation)
ksort($data); $data = $this->recursiveKeySort($data);
// Build signature payload // Build signature payload
$payload = $timestamp . ':' . json_encode( $payload = $timestamp . ':' . json_encode(
@@ -109,6 +109,33 @@ final class ResponseSigner
]; ];
} }
/**
* Recursively sort array keys alphabetically
*
* @param mixed $data The data to sort
* @return mixed The sorted data
*/
private function recursiveKeySort(mixed $data): mixed
{
if (!is_array($data)) {
return $data;
}
// Check if array is associative (has string keys)
$isAssociative = array_keys($data) !== range(0, count($data) - 1);
if ($isAssociative) {
ksort($data);
}
// Recursively sort nested arrays
foreach ($data as $key => $value) {
$data[$key] = $this->recursiveKeySort($value);
}
return $data;
}
/** /**
* Derive a unique signing key for a license * Derive a unique signing key for a license
* *

View File

@@ -95,29 +95,152 @@ final class RestApiController
/** /**
* Get client IP address * Get client IP address
*
* Security note: Only trust proxy headers when explicitly configured.
* Set WC_LICENSE_TRUSTED_PROXIES constant or configure trusted_proxies
* in wp-config.php to enable proxy header support.
*
* @return string Client IP address
*/ */
private function getClientIp(): string private function getClientIp(): string
{ {
// Get the direct connection IP first
$remoteAddr = $_SERVER['REMOTE_ADDR'] ?? '0.0.0.0';
// Only check proxy headers if we're behind a trusted proxy
if ($this->isTrustedProxy($remoteAddr)) {
// Check headers in order of trust preference
$headers = [ $headers = [
'HTTP_CF_CONNECTING_IP', // Cloudflare 'HTTP_CF_CONNECTING_IP', // Cloudflare
'HTTP_X_FORWARDED_FOR', 'HTTP_X_FORWARDED_FOR',
'HTTP_X_REAL_IP', 'HTTP_X_REAL_IP',
'REMOTE_ADDR',
]; ];
foreach ($headers as $header) { foreach ($headers as $header) {
if (!empty($_SERVER[$header])) { if (!empty($_SERVER[$header])) {
$ips = explode(',', $_SERVER[$header]); $ips = explode(',', $_SERVER[$header]);
$ip = trim($ips[0]); $ip = trim($ips[0]);
if (filter_var($ip, FILTER_VALIDATE_IP)) { if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE)) {
return $ip; return $ip;
} }
} }
} }
}
// Validate and return direct connection IP
if (filter_var($remoteAddr, FILTER_VALIDATE_IP)) {
return $remoteAddr;
}
return '0.0.0.0'; return '0.0.0.0';
} }
/**
* Check if the given IP is a trusted proxy
*
* @param string $ip The IP address to check
* @return bool Whether the IP is a trusted proxy
*/
private function isTrustedProxy(string $ip): bool
{
// Check if trusted proxies are configured
if (!defined('WC_LICENSE_TRUSTED_PROXIES')) {
return false;
}
$trustedProxies = WC_LICENSE_TRUSTED_PROXIES;
// Handle string constant (comma-separated list)
if (is_string($trustedProxies)) {
$trustedProxies = array_map('trim', explode(',', $trustedProxies));
}
if (!is_array($trustedProxies)) {
return false;
}
// Check for special keywords
if (in_array('CLOUDFLARE', $trustedProxies, true)) {
// Cloudflare IP ranges (simplified - in production, fetch from Cloudflare API)
if ($this->isCloudflareIp($ip)) {
return true;
}
}
// Check direct IP match or CIDR notation
foreach ($trustedProxies as $proxy) {
if ($proxy === $ip) {
return true;
}
// Support CIDR notation
if (str_contains($proxy, '/') && $this->ipMatchesCidr($ip, $proxy)) {
return true;
}
}
return false;
}
/**
* Check if IP is in Cloudflare range
*
* @param string $ip The IP to check
* @return bool Whether IP belongs to Cloudflare
*/
private function isCloudflareIp(string $ip): bool
{
// Cloudflare IPv4 ranges (as of 2024)
$cloudflareRanges = [
'173.245.48.0/20',
'103.21.244.0/22',
'103.22.200.0/22',
'103.31.4.0/22',
'141.101.64.0/18',
'108.162.192.0/18',
'190.93.240.0/20',
'188.114.96.0/20',
'197.234.240.0/22',
'198.41.128.0/17',
'162.158.0.0/15',
'104.16.0.0/13',
'104.24.0.0/14',
'172.64.0.0/13',
'131.0.72.0/22',
];
foreach ($cloudflareRanges as $range) {
if ($this->ipMatchesCidr($ip, $range)) {
return true;
}
}
return false;
}
/**
* Check if an IP matches a CIDR range
*
* @param string $ip The IP to check
* @param string $cidr The CIDR range (e.g., "192.168.1.0/24")
* @return bool Whether the IP matches the CIDR range
*/
private function ipMatchesCidr(string $ip, string $cidr): bool
{
[$subnet, $bits] = explode('/', $cidr);
if (!filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4) ||
!filter_var($subnet, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) {
return false;
}
$ipLong = ip2long($ip);
$subnetLong = ip2long($subnet);
$mask = -1 << (32 - (int) $bits);
return ($ipLong & $mask) === ($subnetLong & $mask);
}
/** /**
* Register REST API routes * Register REST API routes
*/ */

View File

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

View File

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

View File

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

View File

@@ -55,6 +55,7 @@ final class LicenseEmailController
public function registerEmailClasses(array $email_classes): array public function registerEmailClasses(array $email_classes): array
{ {
$email_classes['WCLP_License_Expiration'] = new LicenseExpirationEmail(); $email_classes['WCLP_License_Expiration'] = new LicenseExpirationEmail();
$email_classes['WCLP_License_Expired'] = new LicenseExpiredEmail();
return $email_classes; return $email_classes;
} }
@@ -69,10 +70,13 @@ final class LicenseEmailController
} }
/** /**
* Send expiration warning emails * Send expiration warning emails and auto-expire licenses
*/ */
public function sendExpirationWarnings(): void public function sendExpirationWarnings(): void
{ {
// First, auto-expire licenses that have passed their expiration date
$this->autoExpireAndNotify();
// Check if expiration emails are enabled in settings // Check if expiration emails are enabled in settings
if (!SettingsController::isExpirationEmailsEnabled()) { if (!SettingsController::isExpirationEmailsEnabled()) {
return; return;
@@ -107,6 +111,41 @@ final class LicenseEmailController
} }
} }
/**
* Auto-expire licenses and send expired notifications
*/
private function autoExpireAndNotify(): void
{
// Get licenses that should be auto-expired
$expiredActiveLicenses = $this->licenseManager->getExpiredActiveLicenses();
if (empty($expiredActiveLicenses)) {
return;
}
// Get the WooCommerce email instance for expired notifications
$mailer = WC()->mailer();
$emails = $mailer->get_emails();
/** @var LicenseExpiredEmail|null $expiredEmail */
$expiredEmail = $emails['WCLP_License_Expired'] ?? null;
foreach ($expiredActiveLicenses as $license) {
// Auto-expire the license
$wasExpired = $this->licenseManager->autoExpireLicense($license->getId());
if ($wasExpired && $expiredEmail && $expiredEmail->is_enabled()) {
// Check if we haven't already sent an expired notification
if (!$this->licenseManager->wasExpirationNotified($license->getId(), 'license_expired')) {
// Send expired notification email
if ($expiredEmail->trigger($license)) {
$this->licenseManager->markExpirationNotified($license->getId(), 'license_expired');
}
}
}
}
}
/** /**
* Process and send expiration warnings for a specific time frame * Process and send expiration warnings for a specific time frame
* *
@@ -155,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
{ {
@@ -164,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');
@@ -252,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'); ?>
@@ -263,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

@@ -0,0 +1,335 @@
<?php
/**
* License Expired Email
*
* @package Jeremias\WcLicensedProduct\Email
*/
declare(strict_types=1);
namespace Jeremias\WcLicensedProduct\Email;
use Jeremias\WcLicensedProduct\License\License;
use WC_Email;
/**
* License Expired Email class
*
* Sends email notifications to customers when their licenses have expired.
* Uses WooCommerce's transactional email system for consistent styling and customization.
*/
class LicenseExpiredEmail extends WC_Email
{
/**
* License object
*/
public ?License $license = null;
/**
* Product name
*/
public string $product_name = '';
/**
* Expiration date formatted
*/
public string $expiration_date = '';
/**
* Customer display name
*/
public string $customer_name = '';
/**
* Constructor
*/
public function __construct()
{
$this->id = 'wclp_license_expired';
$this->customer_email = true;
$this->title = __('License Expired', 'wc-licensed-product');
$this->description = __('License expired emails are sent to customers when their licenses have expired.', 'wc-licensed-product');
$this->placeholders = [
'{site_title}' => $this->get_blogname(),
'{product_name}' => '',
'{expiration_date}' => '',
];
// Call parent constructor
parent::__construct();
}
/**
* Get email subject
*/
public function get_default_subject(): string
{
return __('[{site_title}] Your license for {product_name} has expired', 'wc-licensed-product');
}
/**
* Get email heading
*/
public function get_default_heading(): string
{
return __('License Expired', 'wc-licensed-product');
}
/**
* Trigger the email
*
* @param License $license License object
*/
public function trigger(License $license): bool
{
$this->setup_locale();
$customer = get_userdata($license->getCustomerId());
if (!$customer || !$customer->user_email) {
$this->restore_locale();
return false;
}
$this->license = $license;
$this->recipient = $customer->user_email;
$this->customer_name = $customer->display_name ?: __('Customer', 'wc-licensed-product');
$product = wc_get_product($license->getProductId());
$this->product_name = $product ? $product->get_name() : __('Unknown Product', 'wc-licensed-product');
$expiresAt = $license->getExpiresAt();
$this->expiration_date = $expiresAt ? $expiresAt->format(get_option('date_format')) : '';
// Update placeholders
$this->placeholders['{product_name}'] = $this->product_name;
$this->placeholders['{expiration_date}'] = $this->expiration_date;
if (!$this->is_enabled() || !$this->get_recipient()) {
$this->restore_locale();
return false;
}
$result = $this->send(
$this->get_recipient(),
$this->get_subject(),
$this->get_content(),
$this->get_headers(),
$this->get_attachments()
);
$this->restore_locale();
return $result;
}
/**
* Get content HTML
*/
public function get_content_html(): string
{
ob_start();
// Use WooCommerce's email header
wc_get_template('emails/email-header.php', ['email_heading' => $this->get_heading()]);
$this->render_email_body_html();
// Use WooCommerce's email footer
wc_get_template('emails/email-footer.php', ['email' => $this]);
return ob_get_clean();
}
/**
* Get content plain text
*/
public function get_content_plain(): string
{
ob_start();
echo "=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=\n";
echo esc_html(wp_strip_all_tags($this->get_heading()));
echo "\n=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=\n\n";
$this->render_email_body_plain();
return ob_get_clean();
}
/**
* Render HTML email body content
*/
private function render_email_body_html(): void
{
$account_url = wc_get_account_endpoint_url('licenses');
?>
<p><?php printf(esc_html__('Hello %s,', 'wc-licensed-product'), esc_html($this->customer_name)); ?></p>
<p style="color: #dc3232; font-weight: 600;">
<?php printf(
esc_html__('Your license for %1$s has expired on %2$s.', 'wc-licensed-product'),
'<strong>' . esc_html($this->product_name) . '</strong>',
esc_html($this->expiration_date)
); ?>
</p>
<p>
<?php esc_html_e('Your license is no longer valid and the product will stop working until you renew.', 'wc-licensed-product'); ?>
</p>
<h2><?php esc_html_e('Expired License Details', 'wc-licensed-product'); ?></h2>
<div style="margin-bottom: 40px;">
<table class="td" cellspacing="0" cellpadding="6" style="width: 100%; font-family: 'Helvetica Neue', Helvetica, Roboto, Arial, sans-serif;" border="1">
<tbody>
<tr>
<th class="td" scope="row" style="text-align:<?php echo is_rtl() ? 'right' : 'left'; ?>;"><?php esc_html_e('Product:', 'wc-licensed-product'); ?></th>
<td class="td" style="text-align:<?php echo is_rtl() ? 'right' : 'left'; ?>;"><?php echo esc_html($this->product_name); ?></td>
</tr>
<tr>
<th class="td" scope="row" style="text-align:<?php echo is_rtl() ? 'right' : 'left'; ?>;"><?php esc_html_e('License Key:', 'wc-licensed-product'); ?></th>
<td class="td" style="text-align:<?php echo is_rtl() ? 'right' : 'left'; ?>;">
<code style="background: #f5f5f5; padding: 3px 8px; border-radius: 3px; font-family: monospace;">
<?php echo esc_html($this->license->getLicenseKey()); ?>
</code>
</td>
</tr>
<tr>
<th class="td" scope="row" style="text-align:<?php echo is_rtl() ? 'right' : 'left'; ?>;"><?php esc_html_e('Domain:', 'wc-licensed-product'); ?></th>
<td class="td" style="text-align:<?php echo is_rtl() ? 'right' : 'left'; ?>;"><?php echo esc_html($this->license->getDomain()); ?></td>
</tr>
<tr>
<th class="td" scope="row" style="text-align:<?php echo is_rtl() ? 'right' : 'left'; ?>;"><?php esc_html_e('Expired on:', 'wc-licensed-product'); ?></th>
<td class="td" style="text-align:<?php echo is_rtl() ? 'right' : 'left'; ?>; color: #dc3232; font-weight: 600;"><?php echo esc_html($this->expiration_date); ?></td>
</tr>
<tr>
<th class="td" scope="row" style="text-align:<?php echo is_rtl() ? 'right' : 'left'; ?>;"><?php esc_html_e('Status:', 'wc-licensed-product'); ?></th>
<td class="td" style="text-align:<?php echo is_rtl() ? 'right' : 'left'; ?>;">
<span style="background: #f8d7da; color: #721c24; padding: 3px 10px; border-radius: 12px; font-size: 12px; font-weight: 500;">
<?php esc_html_e('Expired', 'wc-licensed-product'); ?>
</span>
</td>
</tr>
</tbody>
</table>
</div>
<?php
$additional_content = $this->get_additional_content();
if ($additional_content) :
?>
<p><?php echo wp_kses_post($additional_content); ?></p>
<?php endif; ?>
<p style="margin-top: 25px;">
<a href="<?php echo esc_url($account_url); ?>" class="button" style="display: inline-block; background-color: #7f54b3; color: #ffffff; padding: 12px 24px; text-decoration: none; border-radius: 4px; font-weight: 600;">
<?php esc_html_e('View My Licenses', 'wc-licensed-product'); ?>
</a>
</p>
<?php
}
/**
* Render plain text email body content
*/
private function render_email_body_plain(): void
{
printf(esc_html__('Hello %s,', 'wc-licensed-product'), esc_html($this->customer_name));
echo "\n\n";
printf(
esc_html__('Your license for %1$s has expired on %2$s.', 'wc-licensed-product'),
esc_html($this->product_name),
esc_html($this->expiration_date)
);
echo "\n\n";
echo esc_html__('Your license is no longer valid and the product will stop working until you renew.', 'wc-licensed-product');
echo "\n\n";
echo "----------\n";
echo esc_html__('Expired License Details', 'wc-licensed-product') . "\n";
echo "----------\n\n";
echo esc_html__('Product:', 'wc-licensed-product') . ' ' . esc_html($this->product_name) . "\n";
echo esc_html__('License Key:', 'wc-licensed-product') . ' ' . esc_html($this->license->getLicenseKey()) . "\n";
echo esc_html__('Domain:', 'wc-licensed-product') . ' ' . esc_html($this->license->getDomain()) . "\n";
echo esc_html__('Expired on:', 'wc-licensed-product') . ' ' . esc_html($this->expiration_date) . "\n";
echo esc_html__('Status:', 'wc-licensed-product') . ' ' . esc_html__('Expired', 'wc-licensed-product') . "\n\n";
$additional_content = $this->get_additional_content();
if ($additional_content) {
echo "----------\n\n";
echo esc_html(wp_strip_all_tags(wptexturize($additional_content)));
echo "\n\n";
}
echo esc_html__('View My Licenses', 'wc-licensed-product') . ': ' . esc_url(wc_get_account_endpoint_url('licenses')) . "\n\n";
echo "=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=\n";
}
/**
* Default content to show below main email content
*/
public function get_default_additional_content(): string
{
return __('To continue using this product, please renew your license.', 'wc-licensed-product');
}
/**
* Initialize settings form fields
*/
public function init_form_fields(): void
{
$placeholder_text = sprintf(
/* translators: %s: list of placeholders */
__('Available placeholders: %s', 'wc-licensed-product'),
'<code>{site_title}, {product_name}, {expiration_date}</code>'
);
$this->form_fields = [
'enabled' => [
'title' => __('Enable/Disable', 'wc-licensed-product'),
'type' => 'checkbox',
'label' => __('Enable this email notification', 'wc-licensed-product'),
'default' => 'yes',
],
'subject' => [
'title' => __('Subject', 'wc-licensed-product'),
'type' => 'text',
'desc_tip' => true,
'description' => $placeholder_text,
'placeholder' => $this->get_default_subject(),
'default' => '',
],
'heading' => [
'title' => __('Email heading', 'wc-licensed-product'),
'type' => 'text',
'desc_tip' => true,
'description' => $placeholder_text,
'placeholder' => $this->get_default_heading(),
'default' => '',
],
'additional_content' => [
'title' => __('Additional content', 'wc-licensed-product'),
'description' => __('Text to appear below the main email content.', 'wc-licensed-product') . ' ' . $placeholder_text,
'css' => 'width:400px; height: 75px;',
'placeholder' => $this->get_default_additional_content(),
'type' => 'textarea',
'default' => '',
'desc_tip' => true,
],
'email_type' => [
'title' => __('Email type', 'wc-licensed-product'),
'type' => 'select',
'description' => __('Choose which format of email to send.', 'wc-licensed-product'),
'default' => 'html',
'class' => 'email_type wc-enhanced-select',
'options' => $this->get_email_type_options(),
'desc_tip' => true,
],
];
}
}

View File

@@ -107,16 +107,90 @@ 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),
]);
} 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),
];
// 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 +198,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 +207,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

@@ -35,6 +35,9 @@ final class DownloadController
// Add download endpoint // Add download endpoint
add_action('init', [$this, 'addDownloadEndpoint']); add_action('init', [$this, 'addDownloadEndpoint']);
// Register query var for the endpoint
add_filter('query_vars', [$this, 'addDownloadQueryVar']);
// Handle download requests // Handle download requests
add_action('template_redirect', [$this, 'handleDownloadRequest']); add_action('template_redirect', [$this, 'handleDownloadRequest']);
} }
@@ -47,6 +50,15 @@ final class DownloadController
add_rewrite_endpoint('license-download', EP_ROOT | EP_PAGES); add_rewrite_endpoint('license-download', EP_ROOT | EP_PAGES);
} }
/**
* Register the download query var
*/
public function addDownloadQueryVar(array $vars): array
{
$vars[] = 'license-download';
return $vars;
}
/** /**
* Handle download request * Handle download request
*/ */
@@ -160,8 +172,12 @@ final class DownloadController
$downloadUrl = $version->getDownloadUrl(); $downloadUrl = $version->getDownloadUrl();
if ($attachmentId) { if ($attachmentId) {
// Increment download count before serving
$this->versionManager->incrementDownloadCount($versionId);
$this->serveAttachment($attachmentId, $version->getVersion()); $this->serveAttachment($attachmentId, $version->getVersion());
} elseif ($downloadUrl) { } elseif ($downloadUrl) {
// Increment download count before redirect
$this->versionManager->incrementDownloadCount($versionId);
// Redirect to external URL // Redirect to external URL
wp_redirect($downloadUrl); wp_redirect($downloadUrl);
exit; exit;

View File

@@ -35,8 +35,9 @@ final class Installer
// Set version in options // Set version in options
update_option('wc_licensed_product_version', WC_LICENSED_PRODUCT_VERSION); update_option('wc_licensed_product_version', WC_LICENSED_PRODUCT_VERSION);
// Register the licenses endpoint before flushing rewrite rules // Register endpoints before flushing rewrite rules
add_rewrite_endpoint('licenses', EP_ROOT | EP_PAGES); add_rewrite_endpoint('licenses', EP_ROOT | EP_PAGES);
add_rewrite_endpoint('license-download', EP_ROOT | EP_PAGES);
// Flush rewrite rules for REST API and My Account endpoints // Flush rewrite rules for REST API and My Account endpoints
flush_rewrite_rules(); flush_rewrite_rules();
@@ -103,6 +104,7 @@ final class Installer
download_url VARCHAR(512) DEFAULT NULL, download_url VARCHAR(512) DEFAULT NULL,
attachment_id BIGINT UNSIGNED DEFAULT NULL, attachment_id BIGINT UNSIGNED DEFAULT NULL,
file_hash VARCHAR(64) DEFAULT NULL, file_hash VARCHAR(64) DEFAULT NULL,
download_count BIGINT UNSIGNED NOT NULL DEFAULT 0,
is_active TINYINT(1) NOT NULL DEFAULT 1, is_active TINYINT(1) NOT NULL DEFAULT 1,
released_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, released_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,

View File

@@ -49,8 +49,11 @@ class LicenseManager
): ?License { ): ?License {
global $wpdb; global $wpdb;
// Check if license already exists for this order and product // Normalize domain first for duplicate detection
$existing = $this->getLicenseByOrderAndProduct($orderId, $productId); $normalizedDomain = $this->normalizeDomain($domain);
// Check if license already exists for this order, product, and domain
$existing = $this->getLicenseByOrderProductAndDomain($orderId, $productId, $normalizedDomain);
if ($existing) { if ($existing) {
return $existing; return $existing;
} }
@@ -161,6 +164,49 @@ class LicenseManager
return $row ? License::fromArray($row) : null; return $row ? License::fromArray($row) : null;
} }
/**
* Get all licenses for an order and product
*
* @return License[]
*/
public function getLicensesByOrderAndProduct(int $orderId, int $productId): array
{
global $wpdb;
$tableName = Installer::getLicensesTable();
$rows = $wpdb->get_results(
$wpdb->prepare(
"SELECT * FROM {$tableName} WHERE order_id = %d AND product_id = %d ORDER BY created_at ASC",
$orderId,
$productId
),
ARRAY_A
);
return array_map(fn(array $row) => License::fromArray($row), $rows ?: []);
}
/**
* Get license by order, product, and domain
*/
public function getLicenseByOrderProductAndDomain(int $orderId, int $productId, string $domain): ?License
{
global $wpdb;
$tableName = Installer::getLicensesTable();
$row = $wpdb->get_row(
$wpdb->prepare(
"SELECT * FROM {$tableName} WHERE order_id = %d AND product_id = %d AND domain = %s",
$orderId,
$productId,
$domain
),
ARRAY_A
);
return $row ? License::fromArray($row) : null;
}
/** /**
* Get all licenses for an order * Get all licenses for an order
*/ */
@@ -862,6 +908,56 @@ class LicenseManager
return (bool) get_user_meta($license->getCustomerId(), $metaKey, true); return (bool) get_user_meta($license->getCustomerId(), $metaKey, true);
} }
/**
* Get licenses that have passed their expiration date but are still marked as active
*
* @return array Array of License objects that need to be auto-expired
*/
public function getExpiredActiveLicenses(): array
{
global $wpdb;
$tableName = Installer::getLicensesTable();
$now = new \DateTimeImmutable();
$sql = "SELECT * FROM {$tableName}
WHERE expires_at IS NOT NULL
AND expires_at < %s
AND status = %s";
$rows = $wpdb->get_results(
$wpdb->prepare($sql, $now->format('Y-m-d H:i:s'), License::STATUS_ACTIVE),
ARRAY_A
);
return array_map(fn(array $row) => License::fromArray($row), $rows ?: []);
}
/**
* Auto-expire a license and return true if status was changed
*
* @param int $licenseId License ID
* @return bool True if license was expired, false if already expired or error
*/
public function autoExpireLicense(int $licenseId): bool
{
$license = $this->getLicenseById($licenseId);
if (!$license) {
return false;
}
// Only expire if currently active and past expiration date
if ($license->getStatus() !== License::STATUS_ACTIVE) {
return false;
}
if (!$license->isExpired()) {
return false;
}
return $this->updateLicenseStatus($licenseId, License::STATUS_EXPIRED);
}
/** /**
* Import a license from CSV data * Import a license from CSV data
* *

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

@@ -10,6 +10,8 @@ declare(strict_types=1);
namespace Jeremias\WcLicensedProduct; namespace Jeremias\WcLicensedProduct;
use Jeremias\WcLicensedProduct\Admin\AdminController; use Jeremias\WcLicensedProduct\Admin\AdminController;
use Jeremias\WcLicensedProduct\Admin\DashboardWidgetController;
use Jeremias\WcLicensedProduct\Admin\DownloadWidgetController;
use Jeremias\WcLicensedProduct\Admin\OrderLicenseController; use Jeremias\WcLicensedProduct\Admin\OrderLicenseController;
use Jeremias\WcLicensedProduct\Admin\SettingsController; use Jeremias\WcLicensedProduct\Admin\SettingsController;
use Jeremias\WcLicensedProduct\Admin\VersionAdminController; use Jeremias\WcLicensedProduct\Admin\VersionAdminController;
@@ -97,6 +99,7 @@ final class Plugin
$this->twig = new Environment($loader, [ $this->twig = new Environment($loader, [
'cache' => WP_CONTENT_DIR . '/cache/wc-licensed-product/twig', 'cache' => WP_CONTENT_DIR . '/cache/wc-licensed-product/twig',
'auto_reload' => true, // Always check for template changes 'auto_reload' => true, // Always check for template changes
'autoescape' => 'html', // Explicitly enable HTML autoescape for XSS protection
]); ]);
// Add WordPress functions as Twig functions // Add WordPress functions as Twig functions
@@ -151,6 +154,8 @@ final class Plugin
new VersionAdminController($this->versionManager); new VersionAdminController($this->versionManager);
new OrderLicenseController($this->licenseManager); new OrderLicenseController($this->licenseManager);
new SettingsController(); new SettingsController();
new DashboardWidgetController($this->licenseManager);
new DownloadWidgetController($this->versionManager);
// Show admin notice if unlicensed and not on localhost // Show admin notice if unlicensed and not on localhost
if (!$isLicensed && !$licenseChecker->isLocalhost()) { if (!$isLicensed && !$licenseChecker->isLocalhost()) {
@@ -203,13 +208,72 @@ final class Plugin
return; return;
} }
// Try new multi-domain format first
$domainData = $order->get_meta('_licensed_product_domains');
if (!empty($domainData) && is_array($domainData)) {
$this->generateLicensesMultiDomain($order, $domainData);
return;
}
// Fall back to legacy single domain format
$this->generateLicensesSingleDomain($order);
}
/**
* Generate licenses for new multi-domain format
*/
private function generateLicensesMultiDomain(\WC_Order $order, array $domainData): void
{
$orderId = $order->get_id();
$customerId = $order->get_customer_id();
// Index domains by product ID for quick lookup
$domainsByProduct = [];
foreach ($domainData as $item) {
if (isset($item['product_id']) && isset($item['domains']) && is_array($item['domains'])) {
$domainsByProduct[(int) $item['product_id']] = $item['domains'];
}
}
// Generate licenses for each licensed product
foreach ($order->get_items() as $item) {
$product = $item->get_product();
if (!$product || !$product->is_type('licensed')) {
continue;
}
$productId = $product->get_id();
$domains = $domainsByProduct[$productId] ?? [];
// Generate a license for each domain
foreach ($domains as $domain) {
if (!empty($domain)) {
$this->licenseManager->generateLicense(
$orderId,
$productId,
$customerId,
$domain
);
}
}
}
}
/**
* 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) { foreach ($order->get_items() as $item) {
$product = $item->get_product(); $product = $item->get_product();
if ($product && $product->is_type('licensed')) { if ($product && $product->is_type('licensed')) {
$domain = $order->get_meta('_licensed_product_domain');
if ($domain) {
$this->licenseManager->generateLicense( $this->licenseManager->generateLicense(
$orderId, $order->get_id(),
$product->get_id(), $product->get_id(),
$order->get_customer_id(), $order->get_customer_id(),
$domain $domain
@@ -217,7 +281,6 @@ final class Plugin
} }
} }
} }
}
/** /**
* Get Twig environment * Get Twig environment

View File

@@ -45,6 +45,12 @@ final class LicensedProductType
// 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);
// Display current version under product title on single product page
add_action('woocommerce_single_product_summary', [$this, 'displayCurrentVersion'], 6);
// Enqueue frontend CSS for licensed products on single product pages
add_action('wp_enqueue_scripts', [$this, 'enqueueFrontendStyles']);
} }
/** /**
@@ -235,4 +241,52 @@ final class LicensedProductType
} }
return $isVirtual; return $isVirtual;
} }
/**
* Enqueue frontend styles for licensed products on single product pages
*/
public function enqueueFrontendStyles(): void
{
if (!is_product()) {
return;
}
global $product;
if (!$product || !$product->is_type('licensed')) {
return;
}
wp_enqueue_style(
'wc-licensed-product-frontend',
WC_LICENSED_PRODUCT_PLUGIN_URL . 'assets/css/frontend.css',
[],
WC_LICENSED_PRODUCT_VERSION
);
}
/**
* Display current version under product title on single product page
*/
public function displayCurrentVersion(): void
{
global $product;
if (!$product || !$product->is_type('licensed')) {
return;
}
/** @var LicensedProduct $product */
$version = $product->get_current_version();
if (empty($version)) {
return;
}
printf(
'<p class="wclp-product-version"><span class="version-label">%s</span> <span class="version-number">%s</span></p>',
esc_html__('Version:', 'wc-licensed-product'),
esc_html($version)
);
}
} }

View File

@@ -24,6 +24,7 @@ class ProductVersion
private ?string $downloadUrl; private ?string $downloadUrl;
private ?int $attachmentId; private ?int $attachmentId;
private ?string $fileHash; private ?string $fileHash;
private int $downloadCount;
private bool $isActive; private bool $isActive;
private \DateTimeInterface $releasedAt; private \DateTimeInterface $releasedAt;
private \DateTimeInterface $createdAt; private \DateTimeInterface $createdAt;
@@ -44,6 +45,7 @@ class ProductVersion
$version->downloadUrl = $data['download_url'] ?: null; $version->downloadUrl = $data['download_url'] ?: null;
$version->attachmentId = !empty($data['attachment_id']) ? (int) $data['attachment_id'] : null; $version->attachmentId = !empty($data['attachment_id']) ? (int) $data['attachment_id'] : null;
$version->fileHash = $data['file_hash'] ?? null; $version->fileHash = $data['file_hash'] ?? null;
$version->downloadCount = (int) ($data['download_count'] ?? 0);
$version->isActive = (bool) $data['is_active']; $version->isActive = (bool) $data['is_active'];
$version->releasedAt = new \DateTimeImmutable($data['released_at']); $version->releasedAt = new \DateTimeImmutable($data['released_at']);
$version->createdAt = new \DateTimeImmutable($data['created_at']); $version->createdAt = new \DateTimeImmutable($data['created_at']);
@@ -144,6 +146,11 @@ class ProductVersion
return $this->fileHash; return $this->fileHash;
} }
public function getDownloadCount(): int
{
return $this->downloadCount;
}
/** /**
* Get the download URL from attachment * Get the download URL from attachment
*/ */
@@ -197,6 +204,7 @@ class ProductVersion
'download_url' => $this->downloadUrl, 'download_url' => $this->downloadUrl,
'attachment_id' => $this->attachmentId, 'attachment_id' => $this->attachmentId,
'file_hash' => $this->fileHash, 'file_hash' => $this->fileHash,
'download_count' => $this->downloadCount,
'is_active' => $this->isActive, 'is_active' => $this->isActive,
'released_at' => $this->releasedAt->format('Y-m-d H:i:s'), 'released_at' => $this->releasedAt->format('Y-m-d H:i:s'),
'created_at' => $this->createdAt->format('Y-m-d H:i:s'), 'created_at' => $this->createdAt->format('Y-m-d H:i:s'),

View File

@@ -276,4 +276,98 @@ class VersionManager
return (int) $count > 0; return (int) $count > 0;
} }
/**
* Increment download count for a version
*/
public function incrementDownloadCount(int $versionId): bool
{
global $wpdb;
$tableName = Installer::getVersionsTable();
$result = $wpdb->query(
$wpdb->prepare(
"UPDATE {$tableName} SET download_count = download_count + 1 WHERE id = %d",
$versionId
)
);
return $result !== false;
}
/**
* Get total download count across all versions
*/
public function getTotalDownloadCount(): int
{
global $wpdb;
$tableName = Installer::getVersionsTable();
$count = $wpdb->get_var("SELECT COALESCE(SUM(download_count), 0) FROM {$tableName}");
return (int) $count;
}
/**
* Get download statistics per product
*/
public function getDownloadStatistics(): array
{
global $wpdb;
$tableName = Installer::getVersionsTable();
// Get total downloads
$totalDownloads = $this->getTotalDownloadCount();
// Get downloads per product (top 10)
$byProduct = $wpdb->get_results(
"SELECT product_id, SUM(download_count) as downloads
FROM {$tableName}
GROUP BY product_id
ORDER BY downloads DESC
LIMIT 10",
ARRAY_A
);
// Get downloads per version (top 10)
$byVersion = $wpdb->get_results(
"SELECT id, product_id, version, download_count
FROM {$tableName}
WHERE download_count > 0
ORDER BY download_count DESC
LIMIT 10",
ARRAY_A
);
// Enrich product data with names
$productsWithNames = [];
foreach ($byProduct ?: [] as $row) {
$product = wc_get_product((int) $row['product_id']);
$productsWithNames[] = [
'product_id' => (int) $row['product_id'],
'product_name' => $product ? $product->get_name() : __('Unknown Product', 'wc-licensed-product'),
'downloads' => (int) $row['downloads'],
];
}
// Enrich version data with product names
$versionsWithNames = [];
foreach ($byVersion ?: [] as $row) {
$product = wc_get_product((int) $row['product_id']);
$versionsWithNames[] = [
'version_id' => (int) $row['id'],
'product_id' => (int) $row['product_id'],
'product_name' => $product ? $product->get_name() : __('Unknown Product', 'wc-licensed-product'),
'version' => $row['version'],
'downloads' => (int) $row['download_count'],
];
}
return [
'total' => $totalDownloads,
'by_product' => $productsWithNames,
'by_version' => $versionsWithNames,
];
}
} }

View File

@@ -1,6 +1,6 @@
<div class="wrap"> <div class="wrap">
<h1 class="wp-heading-inline">{{ __('Licenses') }}</h1> <h1 class="wp-heading-inline">{{ __('Licenses') }}</h1>
<a href="{{ admin_url }}?action=export_csv" class="page-title-action"> <a href="{{ export_csv_url() }}" class="page-title-action">
<span class="dashicons dashicons-download" style="vertical-align: middle;"></span> <span class="dashicons dashicons-download" style="vertical-align: middle;"></span>
{{ __('Export CSV') }} {{ __('Export CSV') }}
</a> </a>
@@ -143,8 +143,8 @@
</td> </td>
<td class="wclp-editable-cell" data-field="status" data-license-id="{{ item.license.id }}"> <td class="wclp-editable-cell" data-field="status" data-license-id="{{ item.license.id }}">
<span class="wclp-display-value"> <span class="wclp-display-value">
<span class="license-status license-status-{{ item.license.status }}"> <span class="license-status license-status-{{ esc_attr(item.license.status) }}">
{{ item.license.status|capitalize }} {{ esc_html(item.license.status)|capitalize }}
</span> </span>
</span> </span>
<button type="button" class="wclp-edit-btn button-link" title="{{ __('Edit') }}"> <button type="button" class="wclp-edit-btn button-link" title="{{ __('Edit') }}">

View File

@@ -1,68 +1,117 @@
{% 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-{{ item.license.status }}"> <span class="package-order">
{{ 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> <span class="dashicons dashicons-clipboard"></span>
</button> </button>
</div> {% if license.is_transferable %}
<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" <button type="button" class="wclp-transfer-btn"
data-license-id="{{ item.license.id }}" data-license-id="{{ license.id }}"
data-current-domain="{{ esc_attr(item.license.domain) }}" data-current-domain="{{ esc_attr(license.domain) }}"
title="{{ __('Transfer to new domain') }}"> title="{{ __('Transfer to new domain') }}">
<span class="dashicons dashicons-randomize"></span> <span class="dashicons dashicons-randomize"></span>
{{ __('Transfer') }}
</button> </button>
{% endif %} {% 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>
<span><strong>{{ __('Expires:') }}</strong> <span class="license-meta-item license-expiry">
{% if item.license.expiresAt %} <span class="dashicons dashicons-calendar-alt"></span>
{{ item.license.expiresAt|date('Y-m-d') }} {% if license.expires_at %}
{{ license.expires_at|date('Y-m-d') }}
{% else %} {% else %}
{{ __('Never') }} <span class="lifetime">{{ __('Lifetime') }}</span>
{% endif %} {% endif %}
</span> </span>
</div> </div>
</div> </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) #}
<li> {% set latest = package.downloads|first %}
<li class="download-item download-item-latest">
<div class="download-row-file">
<a href="{{ esc_url(latest.download_url) }}" class="download-link">
<span class="dashicons dashicons-download"></span>
{{ esc_html(latest.filename ?: 'Version ' ~ latest.version) }}
</a>
<span class="download-version-badge">{{ __('Latest') }}</span>
</div>
<div class="download-row-meta">
<span class="download-date">{{ esc_html(latest.released_at) }}</span>
{% if latest.file_hash %}
<span class="download-hash" title="{{ esc_attr(latest.file_hash) }}">
<span class="dashicons dashicons-shield"></span>
<code>{{ latest.file_hash[:12] }}...</code>
</span>
{% endif %}
</div>
</li>
</ul>
{# Show older versions in collapsible if more than one version exists #}
{% if package.downloads|length > 1 %}
<div class="older-versions-section">
<button type="button" class="older-versions-toggle" aria-expanded="false">
<span class="dashicons dashicons-arrow-down-alt2"></span>
{{ __('Older versions') }} ({{ package.downloads|length - 1 }})
</button>
<ul class="download-list older-versions-list" style="display: none;">
{% for download in package.downloads|slice(1) %}
<li class="download-item">
<div class="download-row-file">
<a href="{{ esc_url(download.download_url) }}" class="download-link"> <a href="{{ esc_url(download.download_url) }}" class="download-link">
<span class="dashicons dashicons-download"></span> <span class="dashicons dashicons-download"></span>
{{ esc_html(download.filename ?: 'Version ' ~ download.version) }} {{ esc_html(download.filename ?: 'Version ' ~ download.version) }}
</a> </a>
<span class="download-version">v{{ esc_html(download.version) }}</span> </div>
<div class="download-row-meta">
<span class="download-date">{{ esc_html(download.released_at) }}</span> <span class="download-date">{{ esc_html(download.released_at) }}</span>
{% if download.file_hash %} {% if download.file_hash %}
<span class="download-hash" title="{{ esc_attr(download.file_hash) }}"> <span class="download-hash" title="{{ esc_attr(download.file_hash) }}">
@@ -70,12 +119,15 @@
<code>{{ download.file_hash[:12] }}...</code> <code>{{ download.file_hash[:12] }}...</code>
</span> </span>
{% endif %} {% endif %}
</div>
</li> </li>
{% endfor %} {% endfor %}
</ul> </ul>
</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.3 * Version: 0.5.1
* 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.3'); define('WC_LICENSED_PRODUCT_VERSION', '0.5.1');
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__));