diff --git a/CHANGELOG.md b/CHANGELOG.md index 7c8620e..ddcea90 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,110 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.0.7] - 2026-01-21 + +### Added + +- License Dashboard moved to WooCommerce Reports section (Reports > Licenses) +- License search and filtering in admin (by license key, domain, status, product) +- Customer-facing license transfer request with AJAX-based modal +- Email notifications for license expiration warnings (7 days and 1 day before) +- Bulk import licenses from CSV functionality +- Import page with detailed format instructions +- Scheduled cron job for daily expiration checks + +### Changed + +- Dashboard now accessible via WooCommerce > Reports > Licenses tab +- License list page includes search box and filter dropdowns +- Pagination preserves filter state +- Import CSV button added to licenses page header + +### Technical Details + +- AccountController extended with customer transfer AJAX handler and domain normalization +- LicenseEmailController extended with expiration warning scheduling and email templates +- LicenseManager extended with `getLicensesExpiringSoon()`, `markExpirationNotified()`, `wasExpirationNotified()`, `importLicense()` methods +- AdminController extended with CSV import handling and import page rendering +- Installer clears cron events on plugin deactivation +- Frontend JavaScript extended for transfer modal handling +- Frontend CSS extended with modal and transfer button styles + +## [0.0.6] - 2026-01-21 + +### Added + +- License usage statistics/analytics dashboard (WooCommerce > License Dashboard) +- License transfer functionality to change domain from admin +- Export licenses to CSV functionality +- OpenAPI 3.1 specification for REST API documentation (`openapi.json`) +- Monthly license creation chart on dashboard +- Top products and top domains statistics +- Expiring soon alerts on dashboard + +### Removed + +- API endpoint `/deactivate` removed (license deactivation is now admin-only) + +### Changed + +- Licenses admin page header now includes Export CSV button +- Actions column widened to accommodate Transfer link +- Dashboard link added to licenses page + +### Technical Details + +- New admin page: License Dashboard with statistics overview +- LicenseManager extended with `transferLicense()`, `getStatistics()`, `exportLicensesForCsv()` methods +- AdminController extended with dashboard rendering, CSV export, and transfer handling +- Transfer modal with form for domain change +- REST API now only has three endpoints: `/validate`, `/status`, `/activate` +- OpenAPI 3.1 specification documents all API endpoints with examples + +## [0.0.5] - 2026-01-21 + +### Added + +- Bulk license operations in admin (activate, deactivate, revoke, extend, delete) +- License renewal/extension functionality (extend by 30/90/365 days) +- Set license to lifetime (remove expiration) +- Quick action buttons per license row (+30d, ∞, Revoke, Delete) +- Checkbox selection for bulk operations with select-all functionality +- Improved admin notices for bulk operation results + +### Changed + +- Licenses admin page redesigned with WordPress list table styling +- License actions use row-actions pattern for cleaner UI +- Bulk action dropdowns at top and bottom of table (synchronized) + +### Technical Details + +- LicenseManager extended with `extendLicense()`, `setLicenseLifetime()`, `bulkUpdateStatus()`, `bulkDelete()`, `bulkExtend()` methods +- AdminController extended with bulk action handling and extend/lifetime actions +- Twig template functions for generating action URLs with nonces +- JavaScript for checkbox sync and bulk action handling + +## [0.0.4] - 2026-01-21 + +### Added + +- WooCommerce settings tab "Licensed Products" for default license settings +- Default settings for Max Activations, License Validity, and Bind to Major Version +- Per-product settings now override global defaults (empty = use default) +- Settings link in product edit page pointing to WooCommerce settings + +### Changed + +- Product license settings now show placeholder with default values +- LicensedProduct class now falls back to global defaults when product settings are empty + +### Technical Details + +- New class: SettingsController for WooCommerce settings integration +- LicensedProduct model extended with `has_custom_*` methods for checking overrides +- Settings stored using WooCommerce options API + ## [0.0.3] - 2026-01-21 ### Added @@ -67,7 +171,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `POST /wp-json/wc-licensed-product/v1/validate` - Validate license for domain - `POST /wp-json/wc-licensed-product/v1/status` - Check license status - `POST /wp-json/wc-licensed-product/v1/activate` - Activate license on domain - - `POST /wp-json/wc-licensed-product/v1/deactivate` - Deactivate license - Checkout domain field for licensed products - Customer account page "Licenses" to view purchased licenses - Admin interface for license management (WooCommerce > Licenses) @@ -92,7 +195,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - WordPress REST API integration - Custom WooCommerce product type extending WC_Product -[Unreleased]: https://src.bundespruefstelle.ch/magdev/wc-licensed-product/compare/v0.0.3...HEAD +[Unreleased]: https://src.bundespruefstelle.ch/magdev/wc-licensed-product/compare/v0.0.7...HEAD +[0.0.7]: https://src.bundespruefstelle.ch/magdev/wc-licensed-product/compare/v0.0.6...v0.0.7 +[0.0.6]: https://src.bundespruefstelle.ch/magdev/wc-licensed-product/compare/v0.0.5...v0.0.6 +[0.0.5]: https://src.bundespruefstelle.ch/magdev/wc-licensed-product/compare/v0.0.4...v0.0.5 +[0.0.4]: https://src.bundespruefstelle.ch/magdev/wc-licensed-product/compare/v0.0.3...v0.0.4 [0.0.3]: https://src.bundespruefstelle.ch/magdev/wc-licensed-product/compare/v0.0.2...v0.0.3 [0.0.2]: https://src.bundespruefstelle.ch/magdev/wc-licensed-product/compare/v0.0.1...v0.0.2 [0.0.1]: https://src.bundespruefstelle.ch/magdev/wc-licensed-product/releases/tag/v0.0.1 diff --git a/CLAUDE.md b/CLAUDE.md index e18d47b..247f876 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -36,11 +36,9 @@ This project is proudly **"vibe-coded"** using Claude.AI - the entire codebase w _No known bugs at this time._ -### Version 0.0.4 (Next) +### Version 0.0.8 (Next) -- Consider adding license usage statistics/analytics -- Consider adding bulk license operations in admin -- Consider adding license renewal/extension functionality +_No planned features yet. Add items as needed._ ## Technical Stack @@ -225,12 +223,13 @@ Created on plugin activation via `Installer::createTables()`: Base: `/wp-json/wc-licensed-product/v1/` -| Endpoint | Method | Description | -| -------------- | ------ | -------------------------------- | -| `/validate` | POST | Validate license key for domain | -| `/status` | POST | Get license status | -| `/activate` | POST | Activate license on domain | -| `/deactivate` | POST | Deactivate license | +| Endpoint | Method | Description | +| ----------- | ------ | ------------------------------- | +| `/validate` | POST | Validate license key for domain | +| `/status` | POST | Get license status | +| `/activate` | POST | Activate license on domain | + +Full API documentation available in `openapi.json` (OpenAPI 3.1 specification). ### Key Classes @@ -316,3 +315,117 @@ Base: `/wp-json/wc-licensed-product/v1/` **Bug fixes:** - Fixed product versions meta box visibility for non-licensed product types (targets `#wc_licensed_product_versions` container) + +### 2026-01-21 - Version 0.0.4 Features + +**Implemented:** + +- WooCommerce settings tab "Licensed Products" for default license settings +- Default settings for Max Activations, License Validity, and Bind to Major Version +- Per-product settings override global defaults (empty = use default) +- Settings link in product edit page pointing to WooCommerce settings + +**New classes:** + +- `SettingsController` - WooCommerce settings tab integration + +**Technical notes:** + +- Settings stored using WooCommerce options API +- LicensedProduct model falls back to global defaults when product settings are empty +- Added `has_custom_*` methods to check if product has overridden defaults +- Product edit panel shows placeholders with default values + +### 2026-01-21 - Version 0.0.5 Features + +**Implemented:** + +- Bulk license operations in admin (activate, deactivate, revoke, extend, delete) +- License renewal/extension functionality (extend by 30/90/365 days) +- Set license to lifetime (remove expiration) +- Quick action buttons per license row (+30d, ∞, Revoke, Delete) +- Checkbox selection with select-all for bulk operations +- Improved admin notices for bulk operation results + +**New methods in LicenseManager:** + +- `extendLicense()` - Extend license by specified days +- `setLicenseLifetime()` - Remove expiration (set to lifetime) +- `bulkUpdateStatus()` - Bulk update license status +- `bulkDelete()` - Bulk delete licenses +- `bulkExtend()` - Bulk extend licenses + +**Technical notes:** + +- Admin page redesigned with WordPress list table styling +- Twig template functions for generating action URLs with nonces +- JavaScript for checkbox synchronization and bulk action handling +- Row-actions pattern for cleaner per-license action UI + +### 2026-01-21 - Version 0.0.6 Features + +**Implemented:** + +- License usage statistics/analytics dashboard (WooCommerce > License Dashboard) +- License transfer functionality (change domain from admin) +- Export licenses to CSV with all related data +- OpenAPI 3.1 specification for REST API (`openapi.json`) +- Removed `/deactivate` API endpoint (admin-only operation now) + +**New methods in LicenseManager:** + +- `transferLicense()` - Transfer license to new domain +- `getStatistics()` - Get comprehensive license statistics +- `exportLicensesForCsv()` - Export all licenses for CSV download + +**New files:** + +- `templates/admin/dashboard.html.twig` - Dashboard template +- `openapi.json` - OpenAPI 3.1 specification + +**Technical notes:** + +- Dashboard shows status cards, monthly chart, top products/domains +- Transfer uses modal dialog with form submission +- CSV export includes BOM for UTF-8 Excel compatibility +- OpenAPI spec documents all endpoints with examples and error responses +- Statistics query uses SQL aggregation for performance + +### 2026-01-21 - Version 0.0.7 Features + +**Implemented:** + +- License Dashboard moved to WooCommerce Reports section (Reports > Licenses tab) +- License search and filtering in admin (by key, domain, status, product) +- Customer-facing license transfer request with AJAX modal +- Email notifications for license expiration warnings (7 days and 1 day before) +- Bulk import licenses from CSV with validation and options + +**New methods in LicenseManager:** + +- `getLicensesExpiringSoon()` - Get licenses expiring within specified days +- `markExpirationNotified()` - Track expiration notification state +- `wasExpirationNotified()` - Check if notification already sent +- `importLicense()` - Create license from imported data + +**Updated controllers:** + +- `AdminController` - Added Reports integration, search/filter handling, CSV import +- `AccountController` - Added customer transfer AJAX handler with domain validation +- `LicenseEmailController` - Added cron scheduling and expiration warning emails +- `Installer` - Clears cron events on deactivation + +**New UI features:** + +- Search box and filter dropdowns on licenses page +- Transfer button and modal on customer licenses page +- Import CSV page with format documentation +- Pagination preserves filter state + +**Technical notes:** + +- WooCommerce Reports integration uses `woocommerce_admin_reports` filter +- Customer transfer uses AJAX with nonce verification +- Expiration warnings use WordPress cron with daily schedule +- CSV import supports both exported format and simplified format +- User meta tracks expiration notifications to prevent duplicates diff --git a/README.md b/README.md index 3e72684..8b0b0b2 100644 --- a/README.md +++ b/README.md @@ -8,14 +8,34 @@ WC Licensed Product adds a new product type "Licensed Product" to WooCommerce, e ## Features +### Core Features + - **Licensed Product Type**: New WooCommerce product type for software sales -- **Automatic License Generation**: License keys generated on order completion +- **Automatic License Generation**: License keys generated on order completion (format: XXXX-XXXX-XXXX-XXXX) - **Domain Binding**: Licenses are bound to customer-specified domains - **REST API**: Public endpoints for license validation and management -- **Customer Account**: Customers can view their licenses in My Account -- **Admin Management**: Full CRUD interface for license management - **Version Binding**: Optional binding to major software versions - **Expiration Support**: Set license validity periods or lifetime licenses +- **Rate Limiting**: API endpoints protected with rate limiting (30 requests/minute) + +### Customer Features + +- **My Account Licenses**: Customers can view their licenses in My Account +- **License Transfers**: Customers can transfer licenses to new domains +- **Secure Downloads**: Download purchased software versions with license verification +- **Copy to Clipboard**: Easy license key copying + +### Admin Features + +- **License Management**: Full CRUD interface for license management +- **License Dashboard**: Statistics and analytics (WooCommerce > Reports > Licenses) +- **Search & Filtering**: Search by license key, domain, status, or product +- **Bulk Operations**: Activate, deactivate, revoke, extend, or delete multiple licenses +- **License Transfer**: Transfer licenses to new domains +- **CSV Export/Import**: Export and import licenses via CSV +- **Expiration Warnings**: Automatic email notifications before license expiration +- **Version Management**: Manage multiple versions per product with file attachments +- **Global Settings**: Default license settings via WooCommerce settings tab ## Requirements @@ -42,6 +62,19 @@ WC Licensed Product adds a new product type "Licensed Product" to WooCommerce, e - **Bind to Major Version**: Lock license to current major version - **Current Version**: Your software's current version +### Managing Product Versions + +1. Edit a Licensed Product +2. Use the "Product Versions" meta box to add versions +3. Upload version files via WordPress Media Library +4. Version numbers are auto-detected from filenames (e.g., `plugin-v1.2.3.zip`) + +### Global Default Settings + +1. Go to WooCommerce > Settings > Licensed Products +2. Set default values for Max Activations, License Validity, and Version Binding +3. Per-product settings override these defaults + ### Customer Checkout When a customer purchases a licensed product, they must enter the domain where they will use the license during checkout. @@ -50,11 +83,30 @@ When a customer purchases a licensed product, they must enter the domain where t - **Customers**: My Account > Licenses - **Administrators**: WooCommerce > Licenses +- **Dashboard**: WooCommerce > Reports > Licenses (for statistics) + +### Exporting & Importing Licenses + +**Export:** + +1. Go to WooCommerce > Licenses +2. Click "Export CSV" to download all licenses + +**Import:** + +1. Go to WooCommerce > Licenses +2. Click "Import CSV" +3. Upload a CSV file (supports exported format or simplified format) +4. Choose options: skip header row, update existing licenses ## REST API +Full API documentation available in `openapi.json` (OpenAPI 3.1 specification). + ### Validate License +Validate a license key for a specific domain. + ```http POST /wp-json/wc-licensed-product/v1/validate Content-Type: application/json @@ -65,8 +117,33 @@ Content-Type: application/json } ``` +**Success Response (200):** + +```json +{ + "valid": true, + "license": { + "product_id": 123, + "expires_at": "2027-01-21", + "version_id": 5 + } +} +``` + +**Error Response (403):** + +```json +{ + "valid": false, + "error": "domain_mismatch", + "message": "This license is not valid for this domain." +} +``` + ### Check Status +Get detailed license status information. + ```http POST /wp-json/wc-licensed-product/v1/status Content-Type: application/json @@ -76,8 +153,23 @@ Content-Type: application/json } ``` +**Response (200):** + +```json +{ + "valid": true, + "status": "active", + "domain": "example.com", + "expires_at": "2027-01-21", + "activations_count": 1, + "max_activations": 3 +} +``` + ### Activate License +Activate a license on a domain. + ```http POST /wp-json/wc-licensed-product/v1/activate Content-Type: application/json @@ -88,18 +180,27 @@ Content-Type: application/json } ``` -### Deactivate License - -```http -POST /wp-json/wc-licensed-product/v1/deactivate -Content-Type: application/json +**Response (200):** +```json { - "license_key": "XXXX-XXXX-XXXX-XXXX", - "domain": "example.com" + "success": true, + "message": "License activated successfully." } ``` +### Error Codes + +| Code | Description | +| ---- | ----------- | +| `license_not_found` | License key does not exist | +| `license_revoked` | License has been revoked | +| `license_expired` | License has expired | +| `license_inactive` | License is inactive | +| `domain_mismatch` | License not valid for this domain | +| `max_activations_reached` | Maximum activations reached | +| `rate_limit_exceeded` | Too many requests (wait and retry) | + ## License Statuses - **Active**: License is valid and usable @@ -107,6 +208,18 @@ Content-Type: application/json - **Expired**: License validity period has ended - **Revoked**: License has been manually revoked by admin +## Email Notifications + +The plugin sends automatic email notifications: + +- **Order Completion**: License keys included in order confirmation emails +- **Expiration Warning (7 days)**: Reminder sent 7 days before expiration +- **Expiration Warning (1 day)**: Urgent reminder sent 1 day before expiration + +## Changelog + +See [CHANGELOG.md](CHANGELOG.md) for version history and changes. + ## Support For issues and feature requests, please visit: diff --git a/assets/css/admin.css b/assets/css/admin.css index ce60dbf..cca0628 100644 --- a/assets/css/admin.css +++ b/assets/css/admin.css @@ -165,3 +165,306 @@ vertical-align: middle; margin-left: 5px; } + +/* Licenses Table Bulk Actions */ +.licenses-table { + margin-top: 0; +} + +.licenses-table .check-column { + width: 2.2em; + padding: 0.5em; +} + +.licenses-table .license-actions { + width: 150px; +} + +.licenses-table .row-actions { + visibility: visible; + padding: 2px 0 0; +} + +.licenses-table .row-actions a { + text-decoration: none; +} + +.licenses-table .row-actions .submitdelete { + color: #b32d2e; +} + +.licenses-table .row-actions .submitdelete:hover { + color: #dc3232; +} + +/* Lifetime Badge */ +.license-lifetime { + display: inline-block; + padding: 0.2em 0.5em; + font-size: 0.85em; + font-weight: 500; + background-color: #e7f3ff; + color: #2271b1; + border-radius: 3px; +} + +/* Bulk Actions Styling */ +.tablenav .actions select { + margin-right: 6px; +} + +.tablenav .bulkactions { + padding: 0; +} + +/* Transfer Modal */ +.wclp-modal { + position: fixed; + z-index: 100000; + left: 0; + top: 0; + width: 100%; + height: 100%; + background-color: rgba(0, 0, 0, 0.5); +} + +.wclp-modal-content { + background-color: #fff; + margin: 10% auto; + padding: 20px 30px; + border-radius: 4px; + width: 500px; + max-width: 90%; + position: relative; + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.2); +} + +.wclp-modal-close { + position: absolute; + right: 15px; + top: 10px; + font-size: 28px; + font-weight: bold; + cursor: pointer; + color: #666; +} + +.wclp-modal-close:hover { + color: #000; +} + +.wclp-modal h2 { + margin-top: 0; + padding-bottom: 10px; + border-bottom: 1px solid #ddd; +} + +.wclp-modal .form-table th { + padding: 15px 10px 15px 0; + width: 120px; +} + +.wclp-modal .submit { + border-top: 1px solid #ddd; + padding-top: 15px; + margin-bottom: 0; +} + +/* Dashboard Styles */ +.wclp-dashboard-stats { + margin-top: 20px; +} + +.wclp-stat-cards { + display: flex; + flex-wrap: wrap; + gap: 15px; + margin-bottom: 20px; +} + +.wclp-stat-card { + flex: 1; + min-width: 150px; + max-width: 200px; + background: #fff; + border: 1px solid #c3c4c7; + border-radius: 4px; + padding: 15px; + display: flex; + align-items: center; + gap: 12px; +} + +.wclp-stat-icon { + font-size: 32px; + line-height: 1; +} + +.wclp-stat-icon .dashicons { + font-size: 32px; + width: 32px; + height: 32px; +} + +.wclp-stat-content { + display: flex; + flex-direction: column; +} + +.wclp-stat-number { + font-size: 24px; + font-weight: 600; + line-height: 1.2; +} + +.wclp-stat-label { + font-size: 12px; + color: #646970; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.wclp-stat-total .wclp-stat-icon .dashicons { + color: #2271b1; +} + +.wclp-stat-active .wclp-stat-icon .dashicons { + color: #00a32a; +} + +.wclp-stat-inactive .wclp-stat-icon .dashicons { + color: #dba617; +} + +.wclp-stat-expired .wclp-stat-icon .dashicons { + color: #d63638; +} + +.wclp-stat-revoked .wclp-stat-icon .dashicons { + color: #646970; +} + +.wclp-stat-row { + display: flex; + flex-wrap: wrap; + gap: 20px; + margin-bottom: 20px; +} + +.wclp-stat-box { + flex: 1; + min-width: 280px; + background: #fff; + border: 1px solid #c3c4c7; + border-radius: 4px; + padding: 15px 20px; +} + +.wclp-stat-box.wclp-stat-full { + flex: 100%; + min-width: 100%; +} + +.wclp-stat-box h3 { + margin-top: 0; + margin-bottom: 15px; + padding-bottom: 10px; + border-bottom: 1px solid #ddd; +} + +.wclp-stat-box .widefat { + border: none; +} + +.wclp-stat-value { + text-align: right; + font-weight: 600; +} + +.wclp-stat-value.wclp-warning { + color: #d63638; +} + +/* Dashboard Bar Chart */ +.wclp-chart-container { + overflow-x: auto; +} + +.wclp-bar-chart { + display: flex; + align-items: flex-end; + gap: 10px; + height: 200px; + padding: 20px 0; +} + +.wclp-bar-wrapper { + display: flex; + flex-direction: column; + align-items: center; + flex: 1; + min-width: 50px; +} + +.wclp-bar { + width: 100%; + max-width: 40px; + background: linear-gradient(180deg, #2271b1, #135e96); + border-radius: 3px 3px 0 0; + min-height: 4px; + position: relative; + transition: background 0.2s; +} + +.wclp-bar:hover { + background: linear-gradient(180deg, #135e96, #0a4b78); +} + +.wclp-bar-value { + position: absolute; + top: -20px; + left: 50%; + transform: translateX(-50%); + font-size: 11px; + font-weight: 600; + color: #1d2327; +} + +.wclp-bar-label { + margin-top: 8px; + font-size: 10px; + color: #646970; + text-align: center; + white-space: nowrap; +} + +/* Dashboard Actions */ +.wclp-dashboard-actions { + background: #fff; + border: 1px solid #c3c4c7; + border-radius: 4px; + padding: 15px 20px; + margin-top: 20px; +} + +.wclp-dashboard-actions h2 { + margin-top: 0; + margin-bottom: 15px; +} + +.wclp-action-buttons { + display: flex; + flex-wrap: wrap; + gap: 10px; +} + +.wclp-action-buttons .button .dashicons { + vertical-align: middle; + margin-right: 5px; + margin-top: -2px; +} + +/* Wider actions column for transfer link */ +.licenses-table .license-actions { + width: 220px; +} diff --git a/assets/css/frontend.css b/assets/css/frontend.css index 68b9261..860cf71 100644 --- a/assets/css/frontend.css +++ b/assets/css/frontend.css @@ -150,6 +150,39 @@ flex-wrap: wrap; } +.license-domain-display { + display: flex; + align-items: center; + gap: 0.5em; +} + +/* Transfer Button */ +.wclp-transfer-btn { + display: inline-flex; + align-items: center; + gap: 0.25em; + padding: 0.2em 0.6em; + background: #f0f0f0; + border: 1px solid #ddd; + border-radius: 3px; + font-size: 0.85em; + color: #555; + cursor: pointer; + transition: all 0.2s ease; +} + +.wclp-transfer-btn:hover { + background: #e5e5e5; + border-color: #ccc; + color: #333; +} + +.wclp-transfer-btn .dashicons { + font-size: 14px; + width: 14px; + height: 14px; +} + /* Download Section */ .license-downloads { padding: 1em 1.5em; @@ -323,4 +356,151 @@ width: 45%; font-weight: 600; } + + .license-domain-display { + flex-wrap: wrap; + } + + .wclp-transfer-btn { + margin-top: 0.5em; + } +} + +/* Transfer Modal */ +.wclp-modal { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + z-index: 100000; + display: flex; + align-items: center; + justify-content: center; +} + +.wclp-modal-overlay { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: rgba(0, 0, 0, 0.5); +} + +.wclp-modal-content { + position: relative; + background: #fff; + padding: 2em; + border-radius: 8px; + max-width: 450px; + width: 90%; + max-height: 90vh; + overflow-y: auto; + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15); +} + +.wclp-modal-close { + position: absolute; + top: 0.5em; + right: 0.5em; + background: none; + border: none; + font-size: 1.5em; + cursor: pointer; + color: #666; + line-height: 1; + padding: 0.25em; +} + +.wclp-modal-close:hover { + color: #333; +} + +.wclp-modal-content h3 { + margin: 0 0 1.5em 0; + font-size: 1.25em; +} + +.wclp-form-row { + margin-bottom: 1.5em; +} + +.wclp-form-row label { + display: block; + font-weight: 600; + margin-bottom: 0.5em; + color: #333; +} + +.wclp-form-row input[type="text"] { + width: 100%; + padding: 0.75em; + border: 1px solid #ddd; + border-radius: 4px; + font-size: 1em; +} + +.wclp-form-row input[type="text"]:focus { + outline: none; + border-color: #2271b1; + box-shadow: 0 0 0 1px #2271b1; +} + +.wclp-current-domain { + margin: 0; +} + +.wclp-current-domain code { + background: #f5f5f5; + padding: 0.4em 0.8em; + border-radius: 4px; + font-size: 1em; +} + +.wclp-field-description { + margin: 0.5em 0 0 0; + font-size: 0.85em; + color: #666; +} + +.wclp-form-actions { + display: flex; + gap: 1em; + margin-top: 2em; +} + +.wclp-btn-primary { + background: #2271b1 !important; + border-color: #2271b1 !important; + color: #fff !important; +} + +.wclp-btn-primary:hover { + background: #135e96 !important; + border-color: #135e96 !important; +} + +.wclp-btn-primary:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +.wclp-message { + margin-top: 1em; + padding: 0.75em 1em; + border-radius: 4px; + font-size: 0.95em; +} + +.wclp-message.success { + background: #d4edda; + color: #155724; + border: 1px solid #c3e6cb; +} + +.wclp-message.error { + background: #f8d7da; + color: #721c24; + border: 1px solid #f5c6cb; } diff --git a/assets/js/frontend.js b/assets/js/frontend.js index 909e3f9..7380dc7 100644 --- a/assets/js/frontend.js +++ b/assets/js/frontend.js @@ -8,12 +8,29 @@ 'use strict'; var WCLicensedProductFrontend = { + $modal: null, + $form: null, + init: function() { + this.$modal = $('#wclp-transfer-modal'); + this.$form = $('#wclp-transfer-form'); this.bindEvents(); }, bindEvents: function() { $(document).on('click', '.copy-license-btn', this.copyLicenseKey); + + // Transfer modal events + $(document).on('click', '.wclp-transfer-btn', this.openTransferModal.bind(this)); + $(document).on('click', '.wclp-modal-close, .wclp-modal-cancel, .wclp-modal-overlay', this.closeTransferModal.bind(this)); + $(document).on('submit', '#wclp-transfer-form', this.submitTransfer.bind(this)); + + // Close modal on escape key + $(document).on('keyup', function(e) { + if (e.key === 'Escape') { + WCLicensedProductFrontend.closeTransferModal(); + } + }); }, /** @@ -80,6 +97,106 @@ $(this).remove(); }); }, 1500); + }, + + /** + * Open transfer modal + */ + openTransferModal: function(e) { + e.preventDefault(); + + var $btn = $(e.currentTarget); + var licenseId = $btn.data('license-id'); + var currentDomain = $btn.data('current-domain'); + + $('#transfer-license-id').val(licenseId); + $('#transfer-current-domain').text(currentDomain); + $('#transfer-new-domain').val(''); + $('#wclp-transfer-message').hide().removeClass('success error'); + $('#wclp-transfer-submit').prop('disabled', false); + + this.$modal.show(); + $('#transfer-new-domain').focus(); + }, + + /** + * Close transfer modal + */ + closeTransferModal: function(e) { + if (e && $(e.target).closest('.wclp-modal-content').length && !$(e.target).is('.wclp-modal-close, .wclp-modal-cancel')) { + return; + } + this.$modal.hide(); + }, + + /** + * Submit transfer request + */ + submitTransfer: function(e) { + e.preventDefault(); + + var self = this; + var licenseId = $('#transfer-license-id').val(); + var newDomain = $('#transfer-new-domain').val().trim(); + var $message = $('#wclp-transfer-message'); + var $submit = $('#wclp-transfer-submit'); + + // Basic validation + if (!newDomain) { + $message.text(wcLicensedProduct.strings.invalidDomain) + .removeClass('success').addClass('error').show(); + return; + } + + // Confirm transfer + if (!confirm(wcLicensedProduct.strings.transferConfirm)) { + return; + } + + // Disable submit button + $submit.prop('disabled', true); + $message.hide(); + + // Send AJAX request + $.ajax({ + url: wcLicensedProduct.ajaxUrl, + type: 'POST', + data: { + action: 'wclp_customer_transfer_license', + nonce: wcLicensedProduct.transferNonce, + license_id: licenseId, + new_domain: newDomain + }, + success: function(response) { + if (response.success) { + $message.text(response.data.message) + .removeClass('error').addClass('success').show(); + + // Update the domain display in the license card + var $domainDisplay = $('.license-domain-display[data-license-id="' + licenseId + '"]'); + $domainDisplay.find('.domain-value').text(response.data.new_domain); + $domainDisplay.find('.wclp-transfer-btn').data('current-domain', response.data.new_domain); + + // Close modal after a short delay + setTimeout(function() { + self.closeTransferModal(); + }, 1500); + } else { + $message.text(response.data.message || wcLicensedProduct.strings.transferError) + .removeClass('success').addClass('error').show(); + $submit.prop('disabled', false); + } + }, + error: function(xhr) { + var message = wcLicensedProduct.strings.transferError; + if (xhr.responseJSON && xhr.responseJSON.data && xhr.responseJSON.data.message) { + message = xhr.responseJSON.data.message; + } + $message.text(message) + .removeClass('success').addClass('error').show(); + $submit.prop('disabled', false); + } + }); } }; diff --git a/openapi.json b/openapi.json new file mode 100644 index 0000000..6e98b39 --- /dev/null +++ b/openapi.json @@ -0,0 +1,538 @@ +{ + "openapi": "3.1.0", + "info": { + "title": "WooCommerce Licensed Product API", + "description": "REST API for validating and managing software licenses bound to domains. This API allows external applications to validate license keys, check license status, and activate licenses on specific domains.", + "version": "0.0.7", + "contact": { + "name": "Marco Graetsch", + "url": "https://src.bundespruefstelle.ch/magdev", + "email": "magdev3.0@gmail.com" + }, + "license": { + "name": "GPL-2.0-or-later", + "url": "https://www.gnu.org/licenses/gpl-2.0.html" + } + }, + "servers": [ + { + "url": "{baseUrl}/wp-json/wc-licensed-product/v1", + "description": "WordPress REST API endpoint", + "variables": { + "baseUrl": { + "default": "https://example.com", + "description": "The base URL of your WordPress installation" + } + } + } + ], + "paths": { + "/validate": { + "post": { + "operationId": "validateLicense", + "summary": "Validate a license key for a domain", + "description": "Validates whether a license key is valid for a specific domain. Checks license status, expiration, and domain binding. This is the primary endpoint for software to verify license validity.", + "tags": ["License Validation"], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidateRequest" + }, + "example": { + "license_key": "ABCD-1234-EFGH-5678", + "domain": "example.com" + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/ValidateRequest" + } + } + } + }, + "responses": { + "200": { + "description": "License is valid for the specified domain", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidateSuccessResponse" + }, + "example": { + "valid": true, + "license": { + "product_id": 123, + "expires_at": "2027-01-21", + "version_id": 5 + } + } + } + } + }, + "403": { + "description": "License validation failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidateErrorResponse" + }, + "examples": { + "license_not_found": { + "summary": "License key not found", + "value": { + "valid": false, + "error": "license_not_found", + "message": "License key not found." + } + }, + "license_revoked": { + "summary": "License has been revoked", + "value": { + "valid": false, + "error": "license_revoked", + "message": "This license has been revoked." + } + }, + "license_expired": { + "summary": "License has expired", + "value": { + "valid": false, + "error": "license_expired", + "message": "This license has expired." + } + }, + "license_inactive": { + "summary": "License is inactive", + "value": { + "valid": false, + "error": "license_inactive", + "message": "This license is inactive." + } + }, + "domain_mismatch": { + "summary": "License not valid for this domain", + "value": { + "valid": false, + "error": "domain_mismatch", + "message": "This license is not valid for this domain." + } + } + } + } + } + }, + "429": { + "$ref": "#/components/responses/RateLimitExceeded" + } + } + } + }, + "/status": { + "post": { + "operationId": "checkStatus", + "summary": "Get license status information", + "description": "Retrieves detailed status information for a license key, including validity, domain binding, expiration date, and activation counts.", + "tags": ["License Status"], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/StatusRequest" + }, + "example": { + "license_key": "ABCD-1234-EFGH-5678" + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/StatusRequest" + } + } + } + }, + "responses": { + "200": { + "description": "License status retrieved successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/StatusResponse" + }, + "example": { + "valid": true, + "status": "active", + "domain": "example.com", + "expires_at": "2027-01-21", + "activations_count": 1, + "max_activations": 3 + } + } + } + }, + "404": { + "description": "License key not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + }, + "example": { + "valid": false, + "error": "license_not_found", + "message": "License key not found." + } + } + } + }, + "429": { + "$ref": "#/components/responses/RateLimitExceeded" + } + } + } + }, + "/activate": { + "post": { + "operationId": "activateLicense", + "summary": "Activate a license on a domain", + "description": "Activates a license key on a specific domain. If the license is already activated on the same domain, returns success. If activating on a new domain, the old domain binding is replaced (single-domain licenses).", + "tags": ["License Activation"], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ActivateRequest" + }, + "example": { + "license_key": "ABCD-1234-EFGH-5678", + "domain": "newdomain.com" + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/ActivateRequest" + } + } + } + }, + "responses": { + "200": { + "description": "License activated successfully or already activated", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ActivateSuccessResponse" + }, + "examples": { + "activated": { + "summary": "License activated on new domain", + "value": { + "success": true, + "message": "License activated successfully." + } + }, + "already_activated": { + "summary": "License already activated on this domain", + "value": { + "success": true, + "message": "License is already activated for this domain." + } + } + } + } + } + }, + "403": { + "description": "Activation failed due to license restrictions", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + }, + "examples": { + "license_invalid": { + "summary": "License is not valid", + "value": { + "success": false, + "error": "license_invalid", + "message": "This license is not valid." + } + }, + "max_activations_reached": { + "summary": "Maximum activations reached", + "value": { + "success": false, + "error": "max_activations_reached", + "message": "Maximum number of activations reached." + } + } + } + } + } + }, + "404": { + "description": "License key not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + }, + "example": { + "success": false, + "error": "license_not_found", + "message": "License key not found." + } + } + } + }, + "429": { + "$ref": "#/components/responses/RateLimitExceeded" + }, + "500": { + "description": "Server error during activation", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + }, + "example": { + "success": false, + "error": "activation_failed", + "message": "Failed to activate license." + } + } + } + } + } + } + } + }, + "components": { + "schemas": { + "ValidateRequest": { + "type": "object", + "required": ["license_key", "domain"], + "properties": { + "license_key": { + "type": "string", + "description": "The license key to validate (format: XXXX-XXXX-XXXX-XXXX)", + "maxLength": 64, + "example": "ABCD-1234-EFGH-5678" + }, + "domain": { + "type": "string", + "description": "The domain to validate the license against", + "maxLength": 255, + "example": "example.com" + } + } + }, + "ValidateSuccessResponse": { + "type": "object", + "properties": { + "valid": { + "type": "boolean", + "const": true, + "description": "Indicates the license is valid" + }, + "license": { + "type": "object", + "properties": { + "product_id": { + "type": "integer", + "description": "WooCommerce product ID associated with the license" + }, + "expires_at": { + "type": ["string", "null"], + "format": "date", + "description": "Expiration date (null for lifetime licenses)" + }, + "version_id": { + "type": ["integer", "null"], + "description": "Product version ID if license is bound to a version" + } + } + } + } + }, + "ValidateErrorResponse": { + "type": "object", + "properties": { + "valid": { + "type": "boolean", + "const": false, + "description": "Indicates validation failed" + }, + "error": { + "type": "string", + "enum": ["license_not_found", "license_revoked", "license_expired", "license_inactive", "domain_mismatch"], + "description": "Error code for programmatic handling" + }, + "message": { + "type": "string", + "description": "Human-readable error message" + } + } + }, + "StatusRequest": { + "type": "object", + "required": ["license_key"], + "properties": { + "license_key": { + "type": "string", + "description": "The license key to check", + "example": "ABCD-1234-EFGH-5678" + } + } + }, + "StatusResponse": { + "type": "object", + "properties": { + "valid": { + "type": "boolean", + "description": "Whether the license is currently valid" + }, + "status": { + "type": "string", + "enum": ["active", "inactive", "expired", "revoked"], + "description": "Current license status" + }, + "domain": { + "type": "string", + "description": "Domain the license is bound to" + }, + "expires_at": { + "type": ["string", "null"], + "format": "date", + "description": "Expiration date (null for lifetime licenses)" + }, + "activations_count": { + "type": "integer", + "description": "Current number of activations" + }, + "max_activations": { + "type": "integer", + "description": "Maximum allowed activations" + } + } + }, + "ActivateRequest": { + "type": "object", + "required": ["license_key", "domain"], + "properties": { + "license_key": { + "type": "string", + "description": "The license key to activate", + "example": "ABCD-1234-EFGH-5678" + }, + "domain": { + "type": "string", + "description": "The domain to activate the license on", + "example": "newdomain.com" + } + } + }, + "ActivateSuccessResponse": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "const": true, + "description": "Indicates activation was successful" + }, + "message": { + "type": "string", + "description": "Human-readable success message" + } + } + }, + "ErrorResponse": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "const": false, + "description": "Indicates the operation failed" + }, + "valid": { + "type": "boolean", + "const": false, + "description": "Indicates validation failed (for validation endpoints)" + }, + "error": { + "type": "string", + "description": "Error code for programmatic handling" + }, + "message": { + "type": "string", + "description": "Human-readable error message" + } + } + }, + "RateLimitResponse": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "const": false + }, + "error": { + "type": "string", + "const": "rate_limit_exceeded" + }, + "message": { + "type": "string", + "example": "Too many requests. Please try again later." + }, + "retry_after": { + "type": "integer", + "description": "Seconds until rate limit resets" + } + } + } + }, + "responses": { + "RateLimitExceeded": { + "description": "Rate limit exceeded (30 requests per minute per IP)", + "headers": { + "Retry-After": { + "schema": { + "type": "integer" + }, + "description": "Seconds until the rate limit resets" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RateLimitResponse" + }, + "example": { + "success": false, + "error": "rate_limit_exceeded", + "message": "Too many requests. Please try again later.", + "retry_after": 45 + } + } + } + } + } + }, + "tags": [ + { + "name": "License Validation", + "description": "Validate license keys against domains" + }, + { + "name": "License Status", + "description": "Check license status and details" + }, + { + "name": "License Activation", + "description": "Activate licenses on domains" + } + ] +} diff --git a/src/Admin/AdminController.php b/src/Admin/AdminController.php index bb835b8..c05c151 100644 --- a/src/Admin/AdminController.php +++ b/src/Admin/AdminController.php @@ -49,6 +49,9 @@ final class AdminController // HPOS compatibility add_filter('woocommerce_shop_order_list_table_columns', [$this, 'addOrdersLicenseColumn']); add_action('woocommerce_shop_order_list_table_custom_column', [$this, 'displayOrdersLicenseColumnHpos'], 10, 2); + + // Add to WooCommerce Reports + add_filter('woocommerce_admin_reports', [$this, 'addLicenseReports']); } /** @@ -66,12 +69,36 @@ final class AdminController ); } + /** + * Add license reports to WooCommerce Reports + */ + public function addLicenseReports(array $reports): array + { + $reports['licenses'] = [ + 'title' => __('Licenses', 'wc-licensed-product'), + 'reports' => [ + 'overview' => [ + 'title' => __('Overview', 'wc-licensed-product'), + 'description' => '', + 'hide_title' => true, + 'callback' => [$this, 'renderDashboardPage'], + ], + ], + ]; + + return $reports; + } + /** * Enqueue admin styles and scripts */ public function enqueueStyles(string $hook): void { - if ($hook !== 'woocommerce_page_wc-licenses') { + // Check for our pages and WooCommerce Reports page with licenses tab + $isLicensePage = in_array($hook, ['woocommerce_page_wc-licenses', 'woocommerce_page_wc-license-dashboard'], true); + $isReportsPage = $hook === 'woocommerce_page_wc-reports' && isset($_GET['tab']) && $_GET['tab'] === 'licenses'; + + if (!$isLicensePage && !$isReportsPage) { return; } @@ -110,6 +137,41 @@ final class AdminController if (isset($_GET['action']) && $_GET['action'] === 'revoke' && isset($_GET['license_id'])) { $this->handleRevoke(); } + + // Handle extend + if (isset($_GET['action']) && $_GET['action'] === 'extend' && isset($_GET['license_id'])) { + $this->handleExtend(); + } + + // Handle set lifetime + if (isset($_GET['action']) && $_GET['action'] === 'lifetime' && isset($_GET['license_id'])) { + $this->handleSetLifetime(); + } + + // Handle bulk actions + if (isset($_POST['bulk_action']) && !empty($_POST['license_ids'])) { + $this->handleBulkAction(); + } + + // Handle transfer + if (isset($_POST['action']) && $_POST['action'] === 'transfer_license') { + $this->handleTransfer(); + } + + // Handle CSV export + if (isset($_GET['action']) && $_GET['action'] === 'export_csv') { + $this->handleCsvExport(); + } + + // Handle CSV import page + if (isset($_GET['action']) && $_GET['action'] === 'import_csv') { + // Show import form - handled in renderImportPage + } + + // Handle CSV import upload + if (isset($_POST['action']) && $_POST['action'] === 'process_import_csv') { + $this->handleCsvImport(); + } } /** @@ -168,17 +230,505 @@ final class AdminController } } + /** + * Handle license extension + */ + private function handleExtend(): void + { + if (!wp_verify_nonce($_GET['_wpnonce'] ?? '', 'extend_license')) { + wp_die(__('Security check failed.', 'wc-licensed-product')); + } + + $licenseId = absint($_GET['license_id'] ?? 0); + $days = absint($_GET['days'] ?? 30); + + if ($licenseId && $days > 0) { + $this->licenseManager->extendLicense($licenseId, $days); + + wp_redirect(admin_url('admin.php?page=wc-licenses&extended=1')); + exit; + } + } + + /** + * Handle set license to lifetime + */ + private function handleSetLifetime(): void + { + if (!wp_verify_nonce($_GET['_wpnonce'] ?? '', 'lifetime_license')) { + wp_die(__('Security check failed.', 'wc-licensed-product')); + } + + $licenseId = absint($_GET['license_id'] ?? 0); + if ($licenseId) { + $this->licenseManager->setLicenseLifetime($licenseId); + + wp_redirect(admin_url('admin.php?page=wc-licenses&lifetime=1')); + exit; + } + } + + /** + * Handle license transfer + */ + private function handleTransfer(): void + { + if (!wp_verify_nonce($_POST['_wpnonce'] ?? '', 'transfer_license')) { + wp_die(__('Security check failed.', 'wc-licensed-product')); + } + + $licenseId = absint($_POST['license_id'] ?? 0); + $newDomain = sanitize_text_field($_POST['new_domain'] ?? ''); + + if ($licenseId && !empty($newDomain)) { + $success = $this->licenseManager->transferLicense($licenseId, $newDomain); + + if ($success) { + wp_redirect(admin_url('admin.php?page=wc-licenses&transferred=1')); + } else { + wp_redirect(admin_url('admin.php?page=wc-licenses&transfer_failed=1')); + } + exit; + } + + wp_redirect(admin_url('admin.php?page=wc-licenses')); + exit; + } + + /** + * Handle CSV export + */ + private function handleCsvExport(): void + { + if (!current_user_can('manage_woocommerce')) { + wp_die(__('You do not have permission to export licenses.', 'wc-licensed-product')); + } + + $data = $this->licenseManager->exportLicensesForCsv(); + + if (empty($data)) { + wp_redirect(admin_url('admin.php?page=wc-licenses&export_empty=1')); + exit; + } + + // Set headers for CSV download + $filename = 'licenses-export-' . gmdate('Y-m-d-His') . '.csv'; + header('Content-Type: text/csv; charset=utf-8'); + header('Content-Disposition: attachment; filename=' . $filename); + header('Pragma: no-cache'); + header('Expires: 0'); + + $output = fopen('php://output', 'w'); + + // Write BOM for UTF-8 + fwrite($output, "\xEF\xBB\xBF"); + + // Write header row + fputcsv($output, array_keys($data[0])); + + // Write data rows + foreach ($data as $row) { + fputcsv($output, $row); + } + + fclose($output); + exit; + } + + /** + * Handle CSV import + */ + private function handleCsvImport(): void + { + if (!wp_verify_nonce($_POST['_wpnonce'] ?? '', 'import_licenses_csv')) { + wp_die(__('Security check failed.', 'wc-licensed-product')); + } + + if (!current_user_can('manage_woocommerce')) { + wp_die(__('You do not have permission to import licenses.', 'wc-licensed-product')); + } + + // Check if file was uploaded + if (!isset($_FILES['import_file']) || $_FILES['import_file']['error'] !== UPLOAD_ERR_OK) { + wp_redirect(admin_url('admin.php?page=wc-licenses&action=import_csv&import_error=upload')); + exit; + } + + $file = $_FILES['import_file']; + + // Validate file type + $fileType = wp_check_filetype($file['name']); + if ($fileType['ext'] !== 'csv') { + wp_redirect(admin_url('admin.php?page=wc-licenses&action=import_csv&import_error=filetype')); + exit; + } + + // Read the CSV file + $handle = fopen($file['tmp_name'], 'r'); + if (!$handle) { + wp_redirect(admin_url('admin.php?page=wc-licenses&action=import_csv&import_error=read')); + exit; + } + + // Get import options + $skipFirstRow = isset($_POST['skip_first_row']) && $_POST['skip_first_row'] === '1'; + $updateExisting = isset($_POST['update_existing']) && $_POST['update_existing'] === '1'; + + // Skip BOM if present + $bom = fread($handle, 3); + if ($bom !== "\xEF\xBB\xBF") { + rewind($handle); + } + + // Read header row if skipping + if ($skipFirstRow) { + fgetcsv($handle); + } + + $imported = 0; + $updated = 0; + $skipped = 0; + $errors = []; + + while (($row = fgetcsv($handle)) !== false) { + // Skip empty rows + if (empty($row) || (count($row) === 1 && empty($row[0]))) { + continue; + } + + // Map CSV columns (expected format from export): + // ID, License Key, Product, Product ID, Order ID, Order Number, Customer, Customer Email, Customer ID, Domain, Status, Activations, Max Activations, Expires At, Created At, Updated At + // For import we need: License Key (or generate), Product ID, Customer ID, Domain, Status, Max Activations, Expires At + $result = $this->processImportRow($row, $updateExisting); + + if ($result === 'imported') { + $imported++; + } elseif ($result === 'updated') { + $updated++; + } elseif ($result === 'skipped') { + $skipped++; + } else { + $errors[] = $result; + } + } + + fclose($handle); + + // Build redirect URL with results + $redirectUrl = add_query_arg([ + 'page' => 'wc-licenses', + 'imported' => $imported, + 'updated' => $updated, + 'skipped' => $skipped, + 'import_errors' => count($errors), + ], admin_url('admin.php')); + + wp_redirect($redirectUrl); + exit; + } + + /** + * Process a single import row + * + * @param array $row CSV row data + * @param bool $updateExisting Whether to update existing licenses + * @return string Result: 'imported', 'updated', 'skipped', or error message + */ + private function processImportRow(array $row, bool $updateExisting): string + { + // Determine if this is from our export format or simplified format + // Export format has 16 columns, simplified has fewer + + if (count($row) >= 10) { + // Full export format + $licenseKey = trim($row[1] ?? ''); + $productId = absint($row[3] ?? 0); + $orderId = absint($row[4] ?? 0); + $customerId = absint($row[8] ?? 0); + $domain = trim($row[9] ?? ''); + $status = strtolower(trim($row[10] ?? 'active')); + $activationsCount = absint($row[11] ?? 1); + $maxActivations = absint($row[12] ?? 1); + $expiresAt = trim($row[13] ?? ''); + } else { + // Simplified format: License Key, Product ID, Customer ID, Domain, Status, Max Activations, Expires At + $licenseKey = trim($row[0] ?? ''); + $productId = absint($row[1] ?? 0); + $customerId = absint($row[2] ?? 0); + $domain = trim($row[3] ?? ''); + $status = strtolower(trim($row[4] ?? 'active')); + $maxActivations = absint($row[5] ?? 1); + $expiresAt = trim($row[6] ?? ''); + $orderId = 0; + $activationsCount = 1; + } + + // Validate required fields + if (empty($domain)) { + return sprintf(__('Row missing domain', 'wc-licensed-product')); + } + + if ($productId <= 0) { + return sprintf(__('Row missing valid product ID', 'wc-licensed-product')); + } + + // Check if license key already exists + if (!empty($licenseKey)) { + $existing = $this->licenseManager->getLicenseByKey($licenseKey); + if ($existing) { + if ($updateExisting) { + // Update existing license + $this->licenseManager->updateLicenseDomain($existing->getId(), $domain); + if (in_array($status, [License::STATUS_ACTIVE, License::STATUS_INACTIVE, License::STATUS_REVOKED], true)) { + $this->licenseManager->updateLicenseStatus($existing->getId(), $status); + } + return 'updated'; + } + return 'skipped'; + } + } else { + // Generate new license key + $licenseKey = $this->licenseManager->generateLicenseKey(); + while ($this->licenseManager->getLicenseByKey($licenseKey)) { + $licenseKey = $this->licenseManager->generateLicenseKey(); + } + } + + // Normalize status + if (!in_array($status, [License::STATUS_ACTIVE, License::STATUS_INACTIVE, License::STATUS_EXPIRED, License::STATUS_REVOKED], true)) { + $status = License::STATUS_ACTIVE; + } + + // Parse expiration date + $expiresAtParsed = null; + if (!empty($expiresAt) && strtolower($expiresAt) !== 'lifetime') { + try { + $expiresAtParsed = new \DateTimeImmutable($expiresAt); + } catch (\Exception $e) { + // Invalid date, leave as null (lifetime) + } + } + + // Create the license + $result = $this->licenseManager->importLicense( + $licenseKey, + $productId, + $customerId, + $domain, + $orderId, + $status, + $maxActivations, + $activationsCount, + $expiresAtParsed + ); + + return $result ? 'imported' : sprintf(__('Failed to import license for domain %s', 'wc-licensed-product'), $domain); + } + + /** + * Handle bulk actions + */ + private function handleBulkAction(): void + { + if (!wp_verify_nonce($_POST['_wpnonce'] ?? '', 'bulk_license_action')) { + wp_die(__('Security check failed.', 'wc-licensed-product')); + } + + $action = sanitize_text_field($_POST['bulk_action'] ?? ''); + $licenseIds = array_map('absint', (array) ($_POST['license_ids'] ?? [])); + + if (empty($licenseIds)) { + wp_redirect(admin_url('admin.php?page=wc-licenses')); + exit; + } + + $count = 0; + + switch ($action) { + case 'activate': + $count = $this->licenseManager->bulkUpdateStatus($licenseIds, License::STATUS_ACTIVE); + wp_redirect(admin_url('admin.php?page=wc-licenses&bulk_activated=' . $count)); + break; + + case 'deactivate': + $count = $this->licenseManager->bulkUpdateStatus($licenseIds, License::STATUS_INACTIVE); + wp_redirect(admin_url('admin.php?page=wc-licenses&bulk_deactivated=' . $count)); + break; + + case 'revoke': + $count = $this->licenseManager->bulkUpdateStatus($licenseIds, License::STATUS_REVOKED); + wp_redirect(admin_url('admin.php?page=wc-licenses&bulk_revoked=' . $count)); + break; + + case 'delete': + $count = $this->licenseManager->bulkDelete($licenseIds); + wp_redirect(admin_url('admin.php?page=wc-licenses&bulk_deleted=' . $count)); + break; + + case 'extend_30': + $count = $this->licenseManager->bulkExtend($licenseIds, 30); + wp_redirect(admin_url('admin.php?page=wc-licenses&bulk_extended=' . $count)); + break; + + case 'extend_90': + $count = $this->licenseManager->bulkExtend($licenseIds, 90); + wp_redirect(admin_url('admin.php?page=wc-licenses&bulk_extended=' . $count)); + break; + + case 'extend_365': + $count = $this->licenseManager->bulkExtend($licenseIds, 365); + wp_redirect(admin_url('admin.php?page=wc-licenses&bulk_extended=' . $count)); + break; + + default: + wp_redirect(admin_url('admin.php?page=wc-licenses')); + } + + exit; + } + + /** + * Render license dashboard page + */ + public function renderDashboardPage(): void + { + $stats = $this->licenseManager->getStatistics(); + + try { + echo $this->twig->render('admin/dashboard.html.twig', [ + 'stats' => $stats, + 'admin_url' => admin_url('admin.php'), + ]); + } catch (\Exception $e) { + // Fallback to PHP template + $this->renderDashboardPageFallback($stats); + } + } + + /** + * Fallback render for dashboard page + */ + private function renderDashboardPageFallback(array $stats): void + { + ?> +
+

+ +
+
+
+
+
+ + +
+
+
+
+
+ + +
+
+
+
+
+ + +
+
+
+
+
+ + +
+
+
+
+
+ + +
+
+
+ + 0): ?> +
+

+ + + + +

+
+ + + +
+
+ renderImportPage(); + return; + } + $page = isset($_GET['paged']) ? absint($_GET['paged']) : 1; $perPage = 20; - $licenses = $this->licenseManager->getAllLicenses($page, $perPage); - $totalLicenses = $this->licenseManager->getLicenseCount(); - $totalPages = ceil($totalLicenses / $perPage); + // Build filters from query params + $filters = []; + if (!empty($_GET['s'])) { + $filters['search'] = sanitize_text_field($_GET['s']); + } + if (!empty($_GET['status']) && $_GET['status'] !== 'all') { + $filters['status'] = sanitize_text_field($_GET['status']); + } + if (!empty($_GET['product_id'])) { + $filters['product_id'] = absint($_GET['product_id']); + } + + $licenses = $this->licenseManager->getAllLicenses($page, $perPage, $filters); + $totalLicenses = $this->licenseManager->getLicenseCount($filters); + $totalPages = (int) ceil($totalLicenses / $perPage); + + // Get products for filter dropdown + $licensedProducts = $this->licenseManager->getLicensedProducts(); // Enrich licenses with related data $enrichedLicenses = []; @@ -198,6 +748,35 @@ final class AdminController ]; } + // Add URL helper functions to Twig + $this->twig->addFunction(new \Twig\TwigFunction('extend_url', function (int $licenseId, int $days = 30): string { + return wp_nonce_url( + admin_url('admin.php?page=wc-licenses&action=extend&license_id=' . $licenseId . '&days=' . $days), + 'extend_license' + ); + })); + $this->twig->addFunction(new \Twig\TwigFunction('lifetime_url', function (int $licenseId): string { + return wp_nonce_url( + admin_url('admin.php?page=wc-licenses&action=lifetime&license_id=' . $licenseId), + 'lifetime_license' + ); + })); + $this->twig->addFunction(new \Twig\TwigFunction('revoke_url', function (int $licenseId): string { + return wp_nonce_url( + admin_url('admin.php?page=wc-licenses&action=revoke&license_id=' . $licenseId), + 'revoke_license' + ); + })); + $this->twig->addFunction(new \Twig\TwigFunction('delete_url', function (int $licenseId): string { + return wp_nonce_url( + admin_url('admin.php?page=wc-licenses&action=delete&license_id=' . $licenseId), + 'delete_license' + ); + })); + $this->twig->addFunction(new \Twig\TwigFunction('transfer_nonce', function (): string { + return wp_create_nonce('transfer_license'); + })); + try { echo $this->twig->render('admin/licenses.html.twig', [ 'licenses' => $enrichedLicenses, @@ -206,6 +785,8 @@ final class AdminController 'total_licenses' => $totalLicenses, 'admin_url' => admin_url('admin.php?page=wc-licenses'), 'notices' => $this->getNotices(), + 'filters' => $filters, + 'products' => $licensedProducts, ]); } catch (\Exception $e) { // Fallback to PHP template @@ -229,6 +810,99 @@ final class AdminController if (isset($_GET['revoked'])) { $notices[] = ['type' => 'success', 'message' => __('License revoked successfully.', 'wc-licensed-product')]; } + if (isset($_GET['extended'])) { + $notices[] = ['type' => 'success', 'message' => __('License extended successfully.', 'wc-licensed-product')]; + } + if (isset($_GET['lifetime'])) { + $notices[] = ['type' => 'success', 'message' => __('License set to lifetime successfully.', 'wc-licensed-product')]; + } + if (isset($_GET['bulk_activated'])) { + $count = absint($_GET['bulk_activated']); + $notices[] = ['type' => 'success', 'message' => sprintf( + /* translators: %d: number of licenses */ + _n('%d license activated.', '%d licenses activated.', $count, 'wc-licensed-product'), + $count + )]; + } + if (isset($_GET['bulk_deactivated'])) { + $count = absint($_GET['bulk_deactivated']); + $notices[] = ['type' => 'success', 'message' => sprintf( + /* translators: %d: number of licenses */ + _n('%d license deactivated.', '%d licenses deactivated.', $count, 'wc-licensed-product'), + $count + )]; + } + if (isset($_GET['bulk_revoked'])) { + $count = absint($_GET['bulk_revoked']); + $notices[] = ['type' => 'success', 'message' => sprintf( + /* translators: %d: number of licenses */ + _n('%d license revoked.', '%d licenses revoked.', $count, 'wc-licensed-product'), + $count + )]; + } + if (isset($_GET['bulk_deleted'])) { + $count = absint($_GET['bulk_deleted']); + $notices[] = ['type' => 'success', 'message' => sprintf( + /* translators: %d: number of licenses */ + _n('%d license deleted.', '%d licenses deleted.', $count, 'wc-licensed-product'), + $count + )]; + } + if (isset($_GET['bulk_extended'])) { + $count = absint($_GET['bulk_extended']); + $notices[] = ['type' => 'success', 'message' => sprintf( + /* translators: %d: number of licenses */ + _n('%d license extended.', '%d licenses extended.', $count, 'wc-licensed-product'), + $count + )]; + } + if (isset($_GET['transferred'])) { + $notices[] = ['type' => 'success', 'message' => __('License transferred to new domain successfully.', 'wc-licensed-product')]; + } + if (isset($_GET['transfer_failed'])) { + $notices[] = ['type' => 'error', 'message' => __('Failed to transfer license. The license may be revoked or invalid.', 'wc-licensed-product')]; + } + if (isset($_GET['export_empty'])) { + $notices[] = ['type' => 'warning', 'message' => __('No licenses to export.', 'wc-licensed-product')]; + } + if (isset($_GET['imported'])) { + $imported = absint($_GET['imported']); + $updated = absint($_GET['updated'] ?? 0); + $skipped = absint($_GET['skipped'] ?? 0); + $errors = absint($_GET['import_errors'] ?? 0); + + $message = sprintf( + /* translators: %d: number of licenses imported */ + _n('%d license imported.', '%d licenses imported.', $imported, 'wc-licensed-product'), + $imported + ); + + if ($updated > 0) { + $message .= ' ' . sprintf( + /* translators: %d: number of licenses updated */ + _n('%d updated.', '%d updated.', $updated, 'wc-licensed-product'), + $updated + ); + } + + if ($skipped > 0) { + $message .= ' ' . sprintf( + /* translators: %d: number of licenses skipped */ + _n('%d skipped.', '%d skipped.', $skipped, 'wc-licensed-product'), + $skipped + ); + } + + if ($errors > 0) { + $message .= ' ' . sprintf( + /* translators: %d: number of errors */ + _n('%d error.', '%d errors.', $errors, 'wc-licensed-product'), + $errors + ); + } + + $notices[] = ['type' => 'success', 'message' => $message]; + } return $notices; } @@ -248,92 +922,299 @@ final class AdminController - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
getLicenseKey()); ?> - - - - - - - - - - -
- -
getDomain()); ?> - - getStatus())); ?> - - - getExpiresAt(); - echo $expiresAt - ? esc_html($expiresAt->format(get_option('date_format'))) - : esc_html__('Never', 'wc-licensed-product'); - ?> - - getStatus() !== License::STATUS_REVOKED): ?> - - - - - - - -
+

+ +

- 1): ?> -
-
- admin_url('admin.php?page=wc-licenses&paged=%#%'), - 'format' => '', - 'current' => $page, - 'total' => $totalPages, - ]); - ?> +
+ + +
+
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + getLicenseKey()); ?> + + + + + + + + + + +
+ +
getDomain()); ?> + + getStatus())); ?> + + + getExpiresAt(); + if ($expiresAt) { + echo esc_html($expiresAt->format(get_option('date_format'))); + } else { + echo '' . esc_html__('Lifetime', 'wc-licensed-product') . ''; + } + ?> + +
+ getStatus() !== License::STATUS_REVOKED): ?> + + +30d | + + + | + + + + + | + + + + + + + +
+
+ +
+ +
+
+ + +
+ 1): ?> +
+ admin_url('admin.php?page=wc-licenses&paged=%#%'), + 'format' => '', + 'current' => $page, + 'total' => $totalPages, + ]); + ?> +
+ +
+
+ + +
+ +
+

+ + + + +

+ + +
+

+ +

+
+ +
+

+ +

+ +

+ +

+

+ +

+ +
+

+ ID, License Key, Product, Product ID, Order ID, Order Number, Customer, Customer Email, Customer ID, Domain, Status, Activations, Max Activations, Expires At, Created At, Updated At + +

+ License Key, Product ID, Customer ID, Domain, Status, Max Activations, Expires At +
+ +

+
+ -
+ -
+ - +

+ +
+ +
+ + + + + + + + + + + + +
+ + + +

+
+ +

+ +

+ +

+
+ +

+ +

+
+
registerHooks(); + } + + /** + * Register WordPress hooks + */ + private function registerHooks(): void + { + add_filter('woocommerce_settings_tabs_array', [$this, 'addSettingsTab'], 50); + add_action('woocommerce_settings_tabs_licensed_product', [$this, 'renderSettingsTab']); + add_action('woocommerce_update_options_licensed_product', [$this, 'saveSettings']); + } + + /** + * Add settings tab to WooCommerce settings + */ + public function addSettingsTab(array $tabs): array + { + $tabs['licensed_product'] = __('Licensed Products', 'wc-licensed-product'); + return $tabs; + } + + /** + * Get settings fields + */ + public function getSettingsFields(): array + { + return [ + 'section_title' => [ + 'name' => __('Default License Settings', 'wc-licensed-product'), + 'type' => 'title', + 'desc' => __('These settings serve as defaults for new licensed products. Individual product settings override these defaults.', 'wc-licensed-product'), + 'id' => 'wc_licensed_product_section_defaults', + ], + 'default_max_activations' => [ + 'name' => __('Default Max Activations', 'wc-licensed-product'), + 'type' => 'number', + 'desc' => __('Default maximum number of domain activations per license.', 'wc-licensed-product'), + 'id' => 'wc_licensed_product_default_max_activations', + 'default' => '1', + 'custom_attributes' => [ + 'min' => '1', + 'step' => '1', + ], + ], + 'default_validity_days' => [ + 'name' => __('Default License Validity (Days)', 'wc-licensed-product'), + 'type' => 'number', + 'desc' => __('Default number of days a license is valid. Leave empty or set to 0 for lifetime licenses.', 'wc-licensed-product'), + 'id' => 'wc_licensed_product_default_validity_days', + 'default' => '', + 'placeholder' => __('Lifetime', 'wc-licensed-product'), + 'custom_attributes' => [ + 'min' => '0', + 'step' => '1', + ], + ], + 'default_bind_to_version' => [ + 'name' => __('Default Bind to Major Version', 'wc-licensed-product'), + 'type' => 'checkbox', + 'desc' => __('If enabled, licenses are bound to the major version at purchase time by default.', 'wc-licensed-product'), + 'id' => 'wc_licensed_product_default_bind_to_version', + 'default' => 'no', + ], + 'section_end' => [ + 'type' => 'sectionend', + 'id' => 'wc_licensed_product_section_defaults_end', + ], + ]; + } + + /** + * Render settings tab content + */ + public function renderSettingsTab(): void + { + woocommerce_admin_fields($this->getSettingsFields()); + } + + /** + * Save settings + */ + public function saveSettings(): void + { + woocommerce_update_options($this->getSettingsFields()); + } + + /** + * Get default max activations + */ + public static function getDefaultMaxActivations(): int + { + $value = get_option('wc_licensed_product_default_max_activations', 1); + return max(1, (int) $value); + } + + /** + * Get default validity days + */ + public static function getDefaultValidityDays(): ?int + { + $value = get_option('wc_licensed_product_default_validity_days', ''); + if ($value === '' || $value === '0') { + return null; + } + return (int) $value; + } + + /** + * Get default bind to version setting + */ + public static function getDefaultBindToVersion(): bool + { + return get_option('wc_licensed_product_default_bind_to_version', 'no') === 'yes'; + } +} diff --git a/src/Api/RestApiController.php b/src/Api/RestApiController.php index 2ac23c2..d9c3fe8 100644 --- a/src/Api/RestApiController.php +++ b/src/Api/RestApiController.php @@ -180,25 +180,6 @@ final class RestApiController ], ], ]); - - // Deactivate license endpoint (public) - register_rest_route(self::NAMESPACE, '/deactivate', [ - 'methods' => WP_REST_Server::CREATABLE, - 'callback' => [$this, 'deactivateLicense'], - 'permission_callback' => '__return_true', - 'args' => [ - 'license_key' => [ - 'required' => true, - 'type' => 'string', - 'sanitize_callback' => 'sanitize_text_field', - ], - 'domain' => [ - 'required' => true, - 'type' => 'string', - 'sanitize_callback' => 'sanitize_text_field', - ], - ], - ]); } /** @@ -318,55 +299,4 @@ final class RestApiController 'message' => __('License activated successfully.', 'wc-licensed-product'), ]); } - - /** - * Deactivate license endpoint - */ - public function deactivateLicense(WP_REST_Request $request): WP_REST_Response - { - $rateLimitResponse = $this->checkRateLimit(); - if ($rateLimitResponse !== null) { - return $rateLimitResponse; - } - - $licenseKey = $request->get_param('license_key'); - $domain = $request->get_param('domain'); - - $license = $this->licenseManager->getLicenseByKey($licenseKey); - - if (!$license) { - return new WP_REST_Response([ - 'success' => false, - 'error' => 'license_not_found', - 'message' => __('License key not found.', 'wc-licensed-product'), - ], 404); - } - - $normalizedDomain = $this->licenseManager->normalizeDomain($domain); - - // Verify domain matches - if ($license->getDomain() !== $normalizedDomain) { - return new WP_REST_Response([ - 'success' => false, - 'error' => 'domain_mismatch', - 'message' => __('License is not activated for this domain.', 'wc-licensed-product'), - ], 403); - } - - // Set status to inactive - $success = $this->licenseManager->updateLicenseStatus($license->getId(), 'inactive'); - - if (!$success) { - return new WP_REST_Response([ - 'success' => false, - 'error' => 'deactivation_failed', - 'message' => __('Failed to deactivate license.', 'wc-licensed-product'), - ], 500); - } - - return new WP_REST_Response([ - 'success' => true, - 'message' => __('License deactivated successfully.', 'wc-licensed-product'), - ]); - } } diff --git a/src/Email/LicenseEmailController.php b/src/Email/LicenseEmailController.php index d413c54..915a2f7 100644 --- a/src/Email/LicenseEmailController.php +++ b/src/Email/LicenseEmailController.php @@ -34,6 +34,210 @@ final class LicenseEmailController // Add license info to order details in emails add_action('woocommerce_order_item_meta_end', [$this, 'addLicenseToOrderItem'], 10, 4); + + // Schedule cron job for expiration warnings + add_action('init', [$this, 'scheduleExpirationCheck']); + + // Cron action for checking expiring licenses + add_action('wclp_check_expiring_licenses', [$this, 'sendExpirationWarnings']); + } + + /** + * Schedule the expiration check cron job + */ + public function scheduleExpirationCheck(): void + { + if (!wp_next_scheduled('wclp_check_expiring_licenses')) { + wp_schedule_event(time(), 'daily', 'wclp_check_expiring_licenses'); + } + } + + /** + * Send expiration warning emails + */ + public function sendExpirationWarnings(): void + { + // Check for licenses expiring in 7 days + $this->processExpirationWarnings(7, 'expiring_7_days'); + + // Check for licenses expiring in 1 day + $this->processExpirationWarnings(1, 'expiring_1_day'); + } + + /** + * Process and send expiration warnings for a specific time frame + * + * @param int $days Days until expiration + * @param string $notificationType Notification type identifier + */ + private function processExpirationWarnings(int $days, string $notificationType): void + { + $licenses = $this->licenseManager->getLicensesExpiringSoon($days); + + foreach ($licenses as $license) { + // Skip if already notified + if ($this->licenseManager->wasExpirationNotified($license->getId(), $notificationType)) { + continue; + } + + // Send the warning email + if ($this->sendExpirationWarningEmail($license, $days)) { + // Mark as notified + $this->licenseManager->markExpirationNotified($license->getId(), $notificationType); + } + } + } + + /** + * Send expiration warning email to customer + * + * @param \Jeremias\WcLicensedProduct\License\License $license License object + * @param int $daysRemaining Days until expiration + * @return bool Whether email was sent successfully + */ + private function sendExpirationWarningEmail($license, int $daysRemaining): bool + { + $customer = get_userdata($license->getCustomerId()); + if (!$customer || !$customer->user_email) { + return false; + } + + $product = wc_get_product($license->getProductId()); + $productName = $product ? $product->get_name() : __('Unknown Product', 'wc-licensed-product'); + + $siteName = get_bloginfo('name'); + $expiresAt = $license->getExpiresAt(); + $expirationDate = $expiresAt ? $expiresAt->format(get_option('date_format')) : ''; + + // Email subject + if ($daysRemaining === 1) { + $subject = sprintf( + /* translators: 1: Product name, 2: Site name */ + __('[%2$s] Your license for %1$s expires tomorrow', 'wc-licensed-product'), + $productName, + $siteName + ); + } else { + $subject = sprintf( + /* translators: 1: Product name, 2: Number of days, 3: Site name */ + __('[%3$s] Your license for %1$s expires in %2$d days', 'wc-licensed-product'), + $productName, + $daysRemaining, + $siteName + ); + } + + // Email content + $message = $this->buildExpirationWarningHtml($license, $customer, $productName, $daysRemaining, $expirationDate); + + // Send email + $headers = [ + 'Content-Type: text/html; charset=UTF-8', + 'From: ' . $siteName . ' <' . get_option('admin_email') . '>', + ]; + + return wp_mail($customer->user_email, $subject, $message, $headers); + } + + /** + * Build HTML content for expiration warning email + * + * @param \Jeremias\WcLicensedProduct\License\License $license License object + * @param \WP_User $customer Customer user object + * @param string $productName Product name + * @param int $daysRemaining Days until expiration + * @param string $expirationDate Formatted expiration date + * @return string HTML email content + */ + private function buildExpirationWarningHtml($license, $customer, string $productName, int $daysRemaining, string $expirationDate): string + { + $siteName = get_bloginfo('name'); + $siteUrl = home_url(); + $accountUrl = wc_get_account_endpoint_url('licenses'); + + ob_start(); + ?> + + + + + + + +
+

+ +

+ +

display_name)); ?>

+ + +

+ +

+ +

+ +

+ + +
+

+ + + + + + + + + + + + + + + + + +
+ + getLicenseKey()); ?> + +
getDomain()); ?>
+
+ +

+ +

+ + + +

+ +
+ +

+ ' . esc_html($siteName) . '' + ); ?> +

+
+ + +
- getDomain()); ?> + + + getDomain()); ?> + getStatus(), ['active', 'inactive'], true)): ?> + + + getExpiresAt(); @@ -213,6 +228,40 @@ final class AccountController
+ + + admin_url('admin-ajax.php'), + 'transferNonce' => wp_create_nonce('wclp_customer_transfer'), 'strings' => [ 'copied' => __('Copied!', 'wc-licensed-product'), 'copyFailed' => __('Copy failed', 'wc-licensed-product'), + 'transferSuccess' => __('License transferred successfully!', 'wc-licensed-product'), + 'transferError' => __('Transfer failed. Please try again.', 'wc-licensed-product'), + 'transferConfirm' => __('Are you sure you want to transfer this license to a new domain? This action cannot be undone.', 'wc-licensed-product'), + 'invalidDomain' => __('Please enter a valid domain.', 'wc-licensed-product'), ], ]); } + + /** + * Handle AJAX license transfer request from customer + */ + public function handleTransferRequest(): void + { + // Verify nonce + if (!check_ajax_referer('wclp_customer_transfer', 'nonce', false)) { + wp_send_json_error(['message' => __('Security check failed.', 'wc-licensed-product')], 403); + } + + // Verify user is logged in + $customerId = get_current_user_id(); + if (!$customerId) { + wp_send_json_error(['message' => __('Please log in to transfer a license.', 'wc-licensed-product')], 401); + } + + // Get and validate license ID + $licenseId = isset($_POST['license_id']) ? absint($_POST['license_id']) : 0; + if (!$licenseId) { + wp_send_json_error(['message' => __('Invalid license.', 'wc-licensed-product')], 400); + } + + // Get and validate new domain + $newDomain = isset($_POST['new_domain']) ? sanitize_text_field($_POST['new_domain']) : ''; + $newDomain = $this->normalizeDomain($newDomain); + + if (empty($newDomain)) { + wp_send_json_error(['message' => __('Please enter a valid domain.', 'wc-licensed-product')], 400); + } + + // Verify the license belongs to this customer + $license = $this->licenseManager->getLicenseById($licenseId); + if (!$license) { + wp_send_json_error(['message' => __('License not found.', 'wc-licensed-product')], 404); + } + + if ($license->getCustomerId() !== $customerId) { + wp_send_json_error(['message' => __('You do not have permission to transfer this license.', 'wc-licensed-product')], 403); + } + + // Check if license is in a transferable state + if ($license->getStatus() === 'revoked') { + wp_send_json_error(['message' => __('Revoked licenses cannot be transferred.', 'wc-licensed-product')], 400); + } + + if ($license->getStatus() === 'expired') { + wp_send_json_error(['message' => __('Expired licenses cannot be transferred.', 'wc-licensed-product')], 400); + } + + // Check if domain is the same + if ($license->getDomain() === $newDomain) { + wp_send_json_error(['message' => __('The new domain is the same as the current domain.', 'wc-licensed-product')], 400); + } + + // Perform the transfer + $result = $this->licenseManager->transferLicense($licenseId, $newDomain); + + if ($result) { + wp_send_json_success([ + 'message' => __('License transferred successfully!', 'wc-licensed-product'), + 'new_domain' => $newDomain, + ]); + } else { + wp_send_json_error(['message' => __('Failed to transfer license. Please try again.', 'wc-licensed-product')], 500); + } + } + + /** + * Normalize domain for comparison and storage + */ + private function normalizeDomain(string $domain): string + { + // Remove protocol if present + $domain = preg_replace('#^https?://#i', '', $domain); + + // Remove www prefix + $domain = preg_replace('#^www\.#i', '', $domain); + + // Remove trailing slash + $domain = rtrim($domain, '/'); + + // Remove path if present + $domain = explode('/', $domain)[0]; + + // Convert to lowercase + $domain = strtolower($domain); + + // Basic validation - must contain at least one dot and valid characters + if (!preg_match('/^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$/', $domain)) { + return ''; + } + + return $domain; + } } diff --git a/src/Installer.php b/src/Installer.php index f7a66f3..e07807f 100644 --- a/src/Installer.php +++ b/src/Installer.php @@ -44,6 +44,12 @@ final class Installer */ public static function deactivate(): void { + // Clear scheduled cron events + $timestamp = wp_next_scheduled('wclp_check_expiring_licenses'); + if ($timestamp) { + wp_unschedule_event($timestamp, 'wclp_check_expiring_licenses'); + } + // Flush rewrite rules flush_rewrite_rules(); } diff --git a/src/License/LicenseManager.php b/src/License/LicenseManager.php index f49fffe..50a30a1 100644 --- a/src/License/LicenseManager.php +++ b/src/License/LicenseManager.php @@ -176,21 +176,63 @@ class LicenseManager } /** - * Get all licenses (for admin) + * Get all licenses (for admin) with optional filtering + * + * @param int $page Page number + * @param int $perPage Items per page + * @param array $filters Optional filters: search, status, product_id, customer_id + * @return array Array of License objects */ - public function getAllLicenses(int $page = 1, int $perPage = 20): array + public function getAllLicenses(int $page = 1, int $perPage = 20, array $filters = []): array { global $wpdb; $tableName = Installer::getLicensesTable(); $offset = ($page - 1) * $perPage; + $where = []; + $params = []; + + // Search filter (searches license key, domain, customer email) + if (!empty($filters['search'])) { + $search = '%' . $wpdb->esc_like($filters['search']) . '%'; + $where[] = "(license_key LIKE %s OR domain LIKE %s)"; + $params[] = $search; + $params[] = $search; + } + + // Status filter + if (!empty($filters['status']) && in_array($filters['status'], [ + License::STATUS_ACTIVE, + License::STATUS_INACTIVE, + License::STATUS_EXPIRED, + License::STATUS_REVOKED, + ], true)) { + $where[] = "status = %s"; + $params[] = $filters['status']; + } + + // Product filter + if (!empty($filters['product_id'])) { + $where[] = "product_id = %d"; + $params[] = absint($filters['product_id']); + } + + // Customer filter + if (!empty($filters['customer_id'])) { + $where[] = "customer_id = %d"; + $params[] = absint($filters['customer_id']); + } + + $whereClause = !empty($where) ? 'WHERE ' . implode(' AND ', $where) : ''; + + $params[] = $perPage; + $params[] = $offset; + + $sql = "SELECT * FROM {$tableName} {$whereClause} ORDER BY created_at DESC LIMIT %d OFFSET %d"; + $rows = $wpdb->get_results( - $wpdb->prepare( - "SELECT * FROM {$tableName} ORDER BY created_at DESC LIMIT %d OFFSET %d", - $perPage, - $offset - ), + $wpdb->prepare($sql, $params), ARRAY_A ); @@ -198,14 +240,83 @@ class LicenseManager } /** - * Get total license count + * Get total license count with optional filtering + * + * @param array $filters Optional filters: search, status, product_id, customer_id + * @return int Total count */ - public function getLicenseCount(): int + public function getLicenseCount(array $filters = []): int { global $wpdb; $tableName = Installer::getLicensesTable(); - return (int) $wpdb->get_var("SELECT COUNT(*) FROM {$tableName}"); + + $where = []; + $params = []; + + // Search filter + if (!empty($filters['search'])) { + $search = '%' . $wpdb->esc_like($filters['search']) . '%'; + $where[] = "(license_key LIKE %s OR domain LIKE %s)"; + $params[] = $search; + $params[] = $search; + } + + // Status filter + if (!empty($filters['status']) && in_array($filters['status'], [ + License::STATUS_ACTIVE, + License::STATUS_INACTIVE, + License::STATUS_EXPIRED, + License::STATUS_REVOKED, + ], true)) { + $where[] = "status = %s"; + $params[] = $filters['status']; + } + + // Product filter + if (!empty($filters['product_id'])) { + $where[] = "product_id = %d"; + $params[] = absint($filters['product_id']); + } + + // Customer filter + if (!empty($filters['customer_id'])) { + $where[] = "customer_id = %d"; + $params[] = absint($filters['customer_id']); + } + + $whereClause = !empty($where) ? 'WHERE ' . implode(' AND ', $where) : ''; + + if (empty($params)) { + return (int) $wpdb->get_var("SELECT COUNT(*) FROM {$tableName}"); + } + + return (int) $wpdb->get_var( + $wpdb->prepare("SELECT COUNT(*) FROM {$tableName} {$whereClause}", $params) + ); + } + + /** + * Get all licensed products for filter dropdown + * + * @return array Array of [id => name] pairs + */ + public function getLicensedProducts(): array + { + global $wpdb; + + $tableName = Installer::getLicensesTable(); + $productIds = $wpdb->get_col("SELECT DISTINCT product_id FROM {$tableName}"); + + $products = []; + foreach ($productIds as $productId) { + $product = wc_get_product((int) $productId); + if ($product) { + $products[(int) $productId] = $product->get_name(); + } + } + + return $products; } /** @@ -360,4 +471,430 @@ class LicenseManager return $versionId ? (int) $versionId : null; } + + /** + * Extend license expiration + * + * @param int $licenseId License ID + * @param int $days Number of days to extend + * @return bool Success + */ + public function extendLicense(int $licenseId, int $days): bool + { + global $wpdb; + + $license = $this->getLicenseById($licenseId); + if (!$license) { + return false; + } + + // Calculate new expiration date + $currentExpiry = $license->getExpiresAt(); + if ($currentExpiry === null) { + // License is lifetime, set expiration from now + $newExpiry = (new \DateTimeImmutable())->modify("+{$days} days"); + } elseif ($currentExpiry < new \DateTimeImmutable()) { + // License is expired, extend from now + $newExpiry = (new \DateTimeImmutable())->modify("+{$days} days"); + } else { + // License still valid, extend from current expiry + $newExpiry = \DateTimeImmutable::createFromInterface($currentExpiry)->modify("+{$days} days"); + } + + $tableName = Installer::getLicensesTable(); + $result = $wpdb->update( + $tableName, + ['expires_at' => $newExpiry->format('Y-m-d H:i:s')], + ['id' => $licenseId], + ['%s'], + ['%d'] + ); + + // If license was expired, reactivate it + if ($result !== false && $license->getStatus() === License::STATUS_EXPIRED) { + $this->updateLicenseStatus($licenseId, License::STATUS_ACTIVE); + } + + return $result !== false; + } + + /** + * Set license to lifetime (no expiration) + * + * @param int $licenseId License ID + * @return bool Success + */ + public function setLicenseLifetime(int $licenseId): bool + { + global $wpdb; + + $license = $this->getLicenseById($licenseId); + + $tableName = Installer::getLicensesTable(); + // Use raw query to set NULL + $result = $wpdb->query( + $wpdb->prepare( + "UPDATE {$tableName} SET expires_at = NULL WHERE id = %d", + $licenseId + ) + ); + + // If license was expired, reactivate it + if ($result !== false && $license && $license->getStatus() === License::STATUS_EXPIRED) { + $this->updateLicenseStatus($licenseId, License::STATUS_ACTIVE); + } + + return $result !== false; + } + + /** + * Bulk update license status + * + * @param array $licenseIds Array of license IDs + * @param string $status New status + * @return int Number of licenses updated + */ + public function bulkUpdateStatus(array $licenseIds, string $status): int + { + global $wpdb; + + if (empty($licenseIds)) { + return 0; + } + + $tableName = Installer::getLicensesTable(); + $ids = array_map('absint', $licenseIds); + $placeholders = implode(',', array_fill(0, count($ids), '%d')); + + $result = $wpdb->query( + $wpdb->prepare( + "UPDATE {$tableName} SET status = %s WHERE id IN ({$placeholders})", + array_merge([$status], $ids) + ) + ); + + return $result !== false ? (int) $result : 0; + } + + /** + * Bulk delete licenses + * + * @param array $licenseIds Array of license IDs + * @return int Number of licenses deleted + */ + public function bulkDelete(array $licenseIds): int + { + global $wpdb; + + if (empty($licenseIds)) { + return 0; + } + + $tableName = Installer::getLicensesTable(); + $ids = array_map('absint', $licenseIds); + $placeholders = implode(',', array_fill(0, count($ids), '%d')); + + $result = $wpdb->query( + $wpdb->prepare( + "DELETE FROM {$tableName} WHERE id IN ({$placeholders})", + $ids + ) + ); + + return $result !== false ? (int) $result : 0; + } + + /** + * Bulk extend licenses + * + * @param array $licenseIds Array of license IDs + * @param int $days Number of days to extend + * @return int Number of licenses extended + */ + public function bulkExtend(array $licenseIds, int $days): int + { + $count = 0; + foreach ($licenseIds as $licenseId) { + if ($this->extendLicense((int) $licenseId, $days)) { + $count++; + } + } + return $count; + } + + /** + * Transfer license to a new domain + * + * @param int $licenseId License ID + * @param string $newDomain New domain to transfer to + * @return bool Success + */ + public function transferLicense(int $licenseId, string $newDomain): bool + { + $license = $this->getLicenseById($licenseId); + if (!$license) { + return false; + } + + // Cannot transfer revoked licenses + if ($license->getStatus() === License::STATUS_REVOKED) { + return false; + } + + return $this->updateLicenseDomain($licenseId, $newDomain); + } + + /** + * Get license statistics + * + * @return array Statistics data + */ + public function getStatistics(): array + { + global $wpdb; + + $tableName = Installer::getLicensesTable(); + + // Get counts by status + $statusCounts = $wpdb->get_results( + "SELECT status, COUNT(*) as count FROM {$tableName} GROUP BY status", + ARRAY_A + ); + + $byStatus = [ + License::STATUS_ACTIVE => 0, + License::STATUS_INACTIVE => 0, + License::STATUS_EXPIRED => 0, + License::STATUS_REVOKED => 0, + ]; + foreach ($statusCounts ?: [] as $row) { + $byStatus[$row['status']] = (int) $row['count']; + } + + // Get total count + $total = array_sum($byStatus); + + // Get lifetime vs expiring licenses + $lifetimeCount = (int) $wpdb->get_var( + "SELECT COUNT(*) FROM {$tableName} WHERE expires_at IS NULL" + ); + $expiringCount = $total - $lifetimeCount; + + // Get licenses expiring soon (next 30 days) + $expiringSoon = (int) $wpdb->get_var( + $wpdb->prepare( + "SELECT COUNT(*) FROM {$tableName} WHERE expires_at IS NOT NULL AND expires_at <= %s AND expires_at > NOW() AND status = %s", + (new \DateTimeImmutable())->modify('+30 days')->format('Y-m-d H:i:s'), + License::STATUS_ACTIVE + ) + ); + + // Get licenses by product + $byProduct = $wpdb->get_results( + "SELECT product_id, COUNT(*) as count FROM {$tableName} GROUP BY product_id ORDER BY count DESC LIMIT 10", + ARRAY_A + ); + + $productStats = []; + foreach ($byProduct ?: [] as $row) { + $product = wc_get_product((int) $row['product_id']); + $productStats[] = [ + 'product_id' => (int) $row['product_id'], + 'product_name' => $product ? $product->get_name() : __('Unknown Product', 'wc-licensed-product'), + 'count' => (int) $row['count'], + ]; + } + + // Get licenses created per month (last 12 months) + $monthlyData = $wpdb->get_results( + "SELECT DATE_FORMAT(created_at, '%Y-%m') as month, COUNT(*) as count + FROM {$tableName} + WHERE created_at >= DATE_SUB(NOW(), INTERVAL 12 MONTH) + GROUP BY DATE_FORMAT(created_at, '%Y-%m') + ORDER BY month ASC", + ARRAY_A + ); + + $monthlyStats = []; + foreach ($monthlyData ?: [] as $row) { + $monthlyStats[$row['month']] = (int) $row['count']; + } + + // Get top domains + $topDomains = $wpdb->get_results( + "SELECT domain, COUNT(*) as count FROM {$tableName} GROUP BY domain ORDER BY count DESC LIMIT 10", + ARRAY_A + ); + + return [ + 'total' => $total, + 'by_status' => $byStatus, + 'lifetime' => $lifetimeCount, + 'expiring' => $expiringCount, + 'expiring_soon' => $expiringSoon, + 'by_product' => $productStats, + 'monthly' => $monthlyStats, + 'top_domains' => $topDomains ?: [], + ]; + } + + /** + * Get licenses expiring within specified days + * + * @param int $days Number of days to look ahead + * @param bool $excludeNotified Whether to exclude already notified licenses + * @return array Array of License objects with customer data + */ + public function getLicensesExpiringSoon(int $days = 7, bool $excludeNotified = true): array + { + global $wpdb; + + $tableName = Installer::getLicensesTable(); + $now = new \DateTimeImmutable(); + $future = $now->modify("+{$days} days"); + + $sql = "SELECT * FROM {$tableName} + WHERE expires_at IS NOT NULL + AND expires_at > %s + AND expires_at <= %s + AND status = %s"; + + $params = [ + $now->format('Y-m-d H:i:s'), + $future->format('Y-m-d H:i:s'), + License::STATUS_ACTIVE, + ]; + + $rows = $wpdb->get_results( + $wpdb->prepare($sql, $params), + ARRAY_A + ); + + return array_map(fn(array $row) => License::fromArray($row), $rows ?: []); + } + + /** + * Mark license as notified for expiration warning + * + * @param int $licenseId License ID + * @param string $notificationType Type of notification (e.g., 'expiring_7_days', 'expiring_1_day') + * @return bool Success + */ + public function markExpirationNotified(int $licenseId, string $notificationType): bool + { + $metaKey = '_wclp_expiration_notified_' . sanitize_key($notificationType); + update_user_meta($this->getLicenseById($licenseId)?->getCustomerId() ?? 0, $metaKey . '_' . $licenseId, current_time('mysql')); + return true; + } + + /** + * Check if license was already notified for expiration + * + * @param int $licenseId License ID + * @param string $notificationType Type of notification + * @return bool Whether already notified + */ + public function wasExpirationNotified(int $licenseId, string $notificationType): bool + { + $license = $this->getLicenseById($licenseId); + if (!$license) { + return true; // Consider notified if license doesn't exist + } + + $metaKey = '_wclp_expiration_notified_' . sanitize_key($notificationType) . '_' . $licenseId; + return (bool) get_user_meta($license->getCustomerId(), $metaKey, true); + } + + /** + * Import a license from CSV data + * + * @param string $licenseKey License key + * @param int $productId Product ID + * @param int $customerId Customer ID + * @param string $domain Domain name + * @param int $orderId Order ID (optional) + * @param string $status License status + * @param int $maxActivations Maximum activations + * @param int $activationsCount Current activation count + * @param \DateTimeImmutable|null $expiresAt Expiration date or null for lifetime + * @return bool Success + */ + public function importLicense( + string $licenseKey, + int $productId, + int $customerId, + string $domain, + int $orderId = 0, + string $status = License::STATUS_ACTIVE, + int $maxActivations = 1, + int $activationsCount = 1, + ?\DateTimeImmutable $expiresAt = null + ): bool { + global $wpdb; + + $tableName = Installer::getLicensesTable(); + + $result = $wpdb->insert( + $tableName, + [ + 'license_key' => $licenseKey, + 'order_id' => $orderId, + 'product_id' => $productId, + 'customer_id' => $customerId, + 'domain' => $this->normalizeDomain($domain), + 'version_id' => null, + 'status' => $status, + 'activations_count' => $activationsCount, + 'max_activations' => $maxActivations, + 'expires_at' => $expiresAt ? $expiresAt->format('Y-m-d H:i:s') : null, + ], + ['%s', '%d', '%d', '%d', '%s', '%d', '%s', '%d', '%d', '%s'] + ); + + return $result !== false; + } + + /** + * Export all licenses to array format suitable for CSV + * + * @return array Array of license data for CSV export + */ + public function exportLicensesForCsv(): array + { + global $wpdb; + + $tableName = Installer::getLicensesTable(); + $rows = $wpdb->get_results( + "SELECT * FROM {$tableName} ORDER BY created_at DESC", + ARRAY_A + ); + + $exportData = []; + foreach ($rows ?: [] as $row) { + $product = wc_get_product((int) $row['product_id']); + $customer = get_userdata((int) $row['customer_id']); + $order = wc_get_order((int) $row['order_id']); + + $exportData[] = [ + 'ID' => $row['id'], + 'License Key' => $row['license_key'], + 'Product' => $product ? $product->get_name() : 'Unknown', + 'Product ID' => $row['product_id'], + 'Order ID' => $row['order_id'], + 'Order Number' => $order ? $order->get_order_number() : '', + 'Customer' => $customer ? $customer->display_name : 'Guest', + 'Customer Email' => $customer ? $customer->user_email : '', + 'Customer ID' => $row['customer_id'], + 'Domain' => $row['domain'], + 'Status' => ucfirst($row['status']), + 'Activations' => $row['activations_count'], + 'Max Activations' => $row['max_activations'], + 'Expires At' => $row['expires_at'] ?: 'Lifetime', + 'Created At' => $row['created_at'], + 'Updated At' => $row['updated_at'], + ]; + } + + return $exportData; + } } diff --git a/src/Plugin.php b/src/Plugin.php index 95d8347..4204bd9 100644 --- a/src/Plugin.php +++ b/src/Plugin.php @@ -10,6 +10,7 @@ declare(strict_types=1); namespace Jeremias\WcLicensedProduct; use Jeremias\WcLicensedProduct\Admin\AdminController; +use Jeremias\WcLicensedProduct\Admin\SettingsController; use Jeremias\WcLicensedProduct\Admin\VersionAdminController; use Jeremias\WcLicensedProduct\Api\RestApiController; use Jeremias\WcLicensedProduct\Checkout\CheckoutController; @@ -117,6 +118,7 @@ final class Plugin if (is_admin()) { new AdminController($this->twig, $this->licenseManager); new VersionAdminController($this->versionManager); + new SettingsController(); } } diff --git a/src/Product/LicensedProduct.php b/src/Product/LicensedProduct.php index cd5d2f0..17ae0f4 100644 --- a/src/Product/LicensedProduct.php +++ b/src/Product/LicensedProduct.php @@ -9,6 +9,7 @@ declare(strict_types=1); namespace Jeremias\WcLicensedProduct\Product; +use Jeremias\WcLicensedProduct\Admin\SettingsController; use WC_Product; /** @@ -55,28 +56,68 @@ class LicensedProduct extends WC_Product /** * Get max activations for this product + * Falls back to default settings if not set on product */ public function get_max_activations(): int { $value = $this->get_meta('_licensed_max_activations', true); - return $value ? (int) $value : 1; + if ($value !== '' && $value !== null) { + return max(1, (int) $value); + } + return SettingsController::getDefaultMaxActivations(); + } + + /** + * Check if product has custom max activations set + */ + public function has_custom_max_activations(): bool + { + $value = $this->get_meta('_licensed_max_activations', true); + return $value !== '' && $value !== null; } /** * Get validity days + * Falls back to default settings if not set on product */ public function get_validity_days(): ?int { $value = $this->get_meta('_licensed_validity_days', true); - return $value !== '' ? (int) $value : null; + if ($value !== '' && $value !== null) { + return (int) $value > 0 ? (int) $value : null; + } + return SettingsController::getDefaultValidityDays(); + } + + /** + * Check if product has custom validity days set + */ + public function has_custom_validity_days(): bool + { + $value = $this->get_meta('_licensed_validity_days', true); + return $value !== '' && $value !== null; } /** * Check if license should be bound to major version + * Falls back to default settings if not set on product */ public function is_bound_to_version(): bool { - return $this->get_meta('_licensed_bind_to_version', true) === 'yes'; + $value = $this->get_meta('_licensed_bind_to_version', true); + if ($value !== '' && $value !== null) { + return $value === 'yes'; + } + return SettingsController::getDefaultBindToVersion(); + } + + /** + * Check if product has custom bind to version setting + */ + public function has_custom_bind_to_version(): bool + { + $value = $this->get_meta('_licensed_bind_to_version', true); + return $value !== '' && $value !== null; } /** diff --git a/src/Product/LicensedProductType.php b/src/Product/LicensedProductType.php index 26e6afb..78b3bef 100644 --- a/src/Product/LicensedProductType.php +++ b/src/Product/LicensedProductType.php @@ -9,6 +9,8 @@ declare(strict_types=1); namespace Jeremias\WcLicensedProduct\Product; +use Jeremias\WcLicensedProduct\Admin\SettingsController; + /** * Registers and handles the Licensed product type for WooCommerce */ @@ -85,39 +87,82 @@ final class LicensedProductType public function addProductDataPanel(): void { global $post; + + // Get current product values + $currentMaxActivations = get_post_meta($post->ID, '_licensed_max_activations', true); + $currentValidityDays = get_post_meta($post->ID, '_licensed_validity_days', true); + $currentBindToVersion = get_post_meta($post->ID, '_licensed_bind_to_version', true); + + // Get default values + $defaultMaxActivations = SettingsController::getDefaultMaxActivations(); + $defaultValidityDays = SettingsController::getDefaultValidityDays(); + $defaultBindToVersion = SettingsController::getDefaultBindToVersion(); + + // Format default validity for display + $defaultValidityDisplay = $defaultValidityDays !== null + ? sprintf(__('%d days', 'wc-licensed-product'), $defaultValidityDays) + : __('Lifetime', 'wc-licensed-product'); + ?>